+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+3
.gitignore
+3
.gitignore
+12
.prettierrc.json
+12
.prettierrc.json
+3
-1
.tangled/workflows/build.yml
+3
-1
.tangled/workflows/build.yml
+4
-13
.tangled/workflows/fmt.yml
+4
-13
.tangled/workflows/fmt.yml
···
1
1
when:
2
2
- event: ["push", "pull_request"]
3
-
branch: ["master", "ci"]
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"
8
+
- name: "Check formatting"
12
9
command: |
13
-
alejandra -c nix/**/*.nix flake.nix
14
-
15
-
- name: "go fmt"
16
-
command: |
17
-
unformatted=$(gofmt -l .)
18
-
test -z "$unformatted" || (echo "$unformatted" && exit 1)
19
-
10
+
nix run .#fmt -- --ci
+3
-1
.tangled/workflows/test.yml
+3
-1
.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
-
}
+556
-1260
api/tangled/cbor_gen.go
+556
-1260
api/tangled/cbor_gen.go
···
1202
1202
1203
1203
return nil
1204
1204
}
1205
-
func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error {
1206
-
if t == nil {
1207
-
_, err := w.Write(cbg.CborNull)
1208
-
return err
1209
-
}
1210
-
1211
-
cw := cbg.NewCborWriter(w)
1212
-
fieldCount := 3
1213
-
1214
-
if t.LangBreakdown == nil {
1215
-
fieldCount--
1216
-
}
1217
-
1218
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1219
-
return err
1220
-
}
1221
-
1222
-
// t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct)
1223
-
if len("commitCount") > 1000000 {
1224
-
return xerrors.Errorf("Value in field \"commitCount\" was too long")
1225
-
}
1226
-
1227
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil {
1228
-
return err
1229
-
}
1230
-
if _, err := cw.WriteString(string("commitCount")); err != nil {
1231
-
return err
1232
-
}
1233
-
1234
-
if err := t.CommitCount.MarshalCBOR(cw); err != nil {
1235
-
return err
1236
-
}
1237
-
1238
-
// t.IsDefaultRef (bool) (bool)
1239
-
if len("isDefaultRef") > 1000000 {
1240
-
return xerrors.Errorf("Value in field \"isDefaultRef\" was too long")
1241
-
}
1242
-
1243
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil {
1244
-
return err
1245
-
}
1246
-
if _, err := cw.WriteString(string("isDefaultRef")); err != nil {
1247
-
return err
1248
-
}
1249
-
1250
-
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1251
-
return err
1252
-
}
1253
-
1254
-
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
1255
-
if t.LangBreakdown != nil {
1256
-
1257
-
if len("langBreakdown") > 1000000 {
1258
-
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
1259
-
}
1260
-
1261
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
1262
-
return err
1263
-
}
1264
-
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
1265
-
return err
1266
-
}
1267
-
1268
-
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
1269
-
return err
1270
-
}
1271
-
}
1272
-
return nil
1273
-
}
1274
-
1275
-
func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) {
1276
-
*t = GitRefUpdate_Meta{}
1277
-
1278
-
cr := cbg.NewCborReader(r)
1279
-
1280
-
maj, extra, err := cr.ReadHeader()
1281
-
if err != nil {
1282
-
return err
1283
-
}
1284
-
defer func() {
1285
-
if err == io.EOF {
1286
-
err = io.ErrUnexpectedEOF
1287
-
}
1288
-
}()
1289
-
1290
-
if maj != cbg.MajMap {
1291
-
return fmt.Errorf("cbor input should be of type map")
1292
-
}
1293
-
1294
-
if extra > cbg.MaxLength {
1295
-
return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra)
1296
-
}
1297
-
1298
-
n := extra
1299
-
1300
-
nameBuf := make([]byte, 13)
1301
-
for i := uint64(0); i < n; i++ {
1302
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1303
-
if err != nil {
1304
-
return err
1305
-
}
1306
-
1307
-
if !ok {
1308
-
// Field doesn't exist on this type, so ignore it
1309
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1310
-
return err
1311
-
}
1312
-
continue
1313
-
}
1314
-
1315
-
switch string(nameBuf[:nameLen]) {
1316
-
// t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct)
1317
-
case "commitCount":
1318
-
1319
-
{
1320
-
1321
-
b, err := cr.ReadByte()
1322
-
if err != nil {
1323
-
return err
1324
-
}
1325
-
if b != cbg.CborNull[0] {
1326
-
if err := cr.UnreadByte(); err != nil {
1327
-
return err
1328
-
}
1329
-
t.CommitCount = new(GitRefUpdate_Meta_CommitCount)
1330
-
if err := t.CommitCount.UnmarshalCBOR(cr); err != nil {
1331
-
return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err)
1332
-
}
1333
-
}
1334
-
1335
-
}
1336
-
// t.IsDefaultRef (bool) (bool)
1337
-
case "isDefaultRef":
1338
-
1339
-
maj, extra, err = cr.ReadHeader()
1340
-
if err != nil {
1341
-
return err
1342
-
}
1343
-
if maj != cbg.MajOther {
1344
-
return fmt.Errorf("booleans must be major type 7")
1345
-
}
1346
-
switch extra {
1347
-
case 20:
1348
-
t.IsDefaultRef = false
1349
-
case 21:
1350
-
t.IsDefaultRef = true
1351
-
default:
1352
-
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
1353
-
}
1354
-
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
1355
-
case "langBreakdown":
1356
-
1357
-
{
1358
-
1359
-
b, err := cr.ReadByte()
1360
-
if err != nil {
1361
-
return err
1362
-
}
1363
-
if b != cbg.CborNull[0] {
1364
-
if err := cr.UnreadByte(); err != nil {
1365
-
return err
1366
-
}
1367
-
t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown)
1368
-
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
1369
-
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
1370
-
}
1371
-
}
1372
-
1373
-
}
1374
-
1375
-
default:
1376
-
// Field doesn't exist on this type, so ignore it
1377
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1378
-
return err
1379
-
}
1380
-
}
1381
-
}
1382
-
1383
-
return nil
1384
-
}
1385
-
func (t *GitRefUpdate_Meta_CommitCount) MarshalCBOR(w io.Writer) error {
1205
+
func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error {
1386
1206
if t == nil {
1387
1207
_, err := w.Write(cbg.CborNull)
1388
1208
return err
···
1399
1219
return err
1400
1220
}
1401
1221
1402
-
// t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice)
1222
+
// t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice)
1403
1223
if t.ByEmail != nil {
1404
1224
1405
1225
if len("byEmail") > 1000000 {
···
1430
1250
return nil
1431
1251
}
1432
1252
1433
-
func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) {
1434
-
*t = GitRefUpdate_Meta_CommitCount{}
1253
+
func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1254
+
*t = GitRefUpdate_CommitCountBreakdown{}
1435
1255
1436
1256
cr := cbg.NewCborReader(r)
1437
1257
···
1450
1270
}
1451
1271
1452
1272
if extra > cbg.MaxLength {
1453
-
return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra)
1273
+
return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra)
1454
1274
}
1455
1275
1456
1276
n := extra
···
1471
1291
}
1472
1292
1473
1293
switch string(nameBuf[:nameLen]) {
1474
-
// t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice)
1294
+
// t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice)
1475
1295
case "byEmail":
1476
1296
1477
1297
maj, extra, err = cr.ReadHeader()
···
1488
1308
}
1489
1309
1490
1310
if extra > 0 {
1491
-
t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra)
1311
+
t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra)
1492
1312
}
1493
1313
1494
1314
for i := 0; i < int(extra); i++ {
···
1510
1330
if err := cr.UnreadByte(); err != nil {
1511
1331
return err
1512
1332
}
1513
-
t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem)
1333
+
t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount)
1514
1334
if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil {
1515
1335
return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err)
1516
1336
}
···
1531
1351
1532
1352
return nil
1533
1353
}
1534
-
func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error {
1354
+
func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error {
1535
1355
if t == nil {
1536
1356
_, err := w.Write(cbg.CborNull)
1537
1357
return err
···
1590
1410
return nil
1591
1411
}
1592
1412
1593
-
func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) {
1594
-
*t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}
1413
+
func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) {
1414
+
*t = GitRefUpdate_IndividualEmailCommitCount{}
1595
1415
1596
1416
cr := cbg.NewCborReader(r)
1597
1417
···
1610
1430
}
1611
1431
1612
1432
if extra > cbg.MaxLength {
1613
-
return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra)
1433
+
return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra)
1614
1434
}
1615
1435
1616
1436
n := extra
···
1679
1499
1680
1500
return nil
1681
1501
}
1682
-
func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error {
1502
+
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
1683
1503
if t == nil {
1684
1504
_, err := w.Write(cbg.CborNull)
1685
1505
return err
···
1696
1516
return err
1697
1517
}
1698
1518
1699
-
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1519
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1700
1520
if t.Inputs != nil {
1701
1521
1702
1522
if len("inputs") > 1000000 {
···
1727
1547
return nil
1728
1548
}
1729
1549
1730
-
func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1731
-
*t = GitRefUpdate_Meta_LangBreakdown{}
1550
+
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1551
+
*t = GitRefUpdate_LangBreakdown{}
1732
1552
1733
1553
cr := cbg.NewCborReader(r)
1734
1554
···
1747
1567
}
1748
1568
1749
1569
if extra > cbg.MaxLength {
1750
-
return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra)
1570
+
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
1751
1571
}
1752
1572
1753
1573
n := extra
···
1768
1588
}
1769
1589
1770
1590
switch string(nameBuf[:nameLen]) {
1771
-
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1591
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1772
1592
case "inputs":
1773
1593
1774
1594
maj, extra, err = cr.ReadHeader()
···
1785
1605
}
1786
1606
1787
1607
if extra > 0 {
1788
-
t.Inputs = make([]*GitRefUpdate_Pair, extra)
1608
+
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
1789
1609
}
1790
1610
1791
1611
for i := 0; i < int(extra); i++ {
···
1807
1627
if err := cr.UnreadByte(); err != nil {
1808
1628
return err
1809
1629
}
1810
-
t.Inputs[i] = new(GitRefUpdate_Pair)
1630
+
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
1811
1631
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1812
1632
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1813
1633
}
···
1828
1648
1829
1649
return nil
1830
1650
}
1831
-
func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error {
1651
+
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
1832
1652
if t == nil {
1833
1653
_, err := w.Write(cbg.CborNull)
1834
1654
return err
···
1888
1708
return nil
1889
1709
}
1890
1710
1891
-
func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) {
1892
-
*t = GitRefUpdate_Pair{}
1711
+
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
1712
+
*t = GitRefUpdate_IndividualLanguageSize{}
1893
1713
1894
1714
cr := cbg.NewCborReader(r)
1895
1715
···
1908
1728
}
1909
1729
1910
1730
if extra > cbg.MaxLength {
1911
-
return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra)
1731
+
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
1912
1732
}
1913
1733
1914
1734
n := extra
···
1977
1797
1978
1798
return nil
1979
1799
}
1800
+
func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error {
1801
+
if t == nil {
1802
+
_, err := w.Write(cbg.CborNull)
1803
+
return err
1804
+
}
1805
+
1806
+
cw := cbg.NewCborWriter(w)
1807
+
fieldCount := 3
1808
+
1809
+
if t.LangBreakdown == nil {
1810
+
fieldCount--
1811
+
}
1812
+
1813
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1814
+
return err
1815
+
}
1816
+
1817
+
// t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct)
1818
+
if len("commitCount") > 1000000 {
1819
+
return xerrors.Errorf("Value in field \"commitCount\" was too long")
1820
+
}
1821
+
1822
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil {
1823
+
return err
1824
+
}
1825
+
if _, err := cw.WriteString(string("commitCount")); err != nil {
1826
+
return err
1827
+
}
1828
+
1829
+
if err := t.CommitCount.MarshalCBOR(cw); err != nil {
1830
+
return err
1831
+
}
1832
+
1833
+
// t.IsDefaultRef (bool) (bool)
1834
+
if len("isDefaultRef") > 1000000 {
1835
+
return xerrors.Errorf("Value in field \"isDefaultRef\" was too long")
1836
+
}
1837
+
1838
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil {
1839
+
return err
1840
+
}
1841
+
if _, err := cw.WriteString(string("isDefaultRef")); err != nil {
1842
+
return err
1843
+
}
1844
+
1845
+
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1846
+
return err
1847
+
}
1848
+
1849
+
// t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct)
1850
+
if t.LangBreakdown != nil {
1851
+
1852
+
if len("langBreakdown") > 1000000 {
1853
+
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
1854
+
}
1855
+
1856
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
1857
+
return err
1858
+
}
1859
+
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
1860
+
return err
1861
+
}
1862
+
1863
+
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
1864
+
return err
1865
+
}
1866
+
}
1867
+
return nil
1868
+
}
1869
+
1870
+
func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) {
1871
+
*t = GitRefUpdate_Meta{}
1872
+
1873
+
cr := cbg.NewCborReader(r)
1874
+
1875
+
maj, extra, err := cr.ReadHeader()
1876
+
if err != nil {
1877
+
return err
1878
+
}
1879
+
defer func() {
1880
+
if err == io.EOF {
1881
+
err = io.ErrUnexpectedEOF
1882
+
}
1883
+
}()
1884
+
1885
+
if maj != cbg.MajMap {
1886
+
return fmt.Errorf("cbor input should be of type map")
1887
+
}
1888
+
1889
+
if extra > cbg.MaxLength {
1890
+
return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra)
1891
+
}
1892
+
1893
+
n := extra
1894
+
1895
+
nameBuf := make([]byte, 13)
1896
+
for i := uint64(0); i < n; i++ {
1897
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1898
+
if err != nil {
1899
+
return err
1900
+
}
1901
+
1902
+
if !ok {
1903
+
// Field doesn't exist on this type, so ignore it
1904
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1905
+
return err
1906
+
}
1907
+
continue
1908
+
}
1909
+
1910
+
switch string(nameBuf[:nameLen]) {
1911
+
// t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct)
1912
+
case "commitCount":
1913
+
1914
+
{
1915
+
1916
+
b, err := cr.ReadByte()
1917
+
if err != nil {
1918
+
return err
1919
+
}
1920
+
if b != cbg.CborNull[0] {
1921
+
if err := cr.UnreadByte(); err != nil {
1922
+
return err
1923
+
}
1924
+
t.CommitCount = new(GitRefUpdate_CommitCountBreakdown)
1925
+
if err := t.CommitCount.UnmarshalCBOR(cr); err != nil {
1926
+
return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err)
1927
+
}
1928
+
}
1929
+
1930
+
}
1931
+
// t.IsDefaultRef (bool) (bool)
1932
+
case "isDefaultRef":
1933
+
1934
+
maj, extra, err = cr.ReadHeader()
1935
+
if err != nil {
1936
+
return err
1937
+
}
1938
+
if maj != cbg.MajOther {
1939
+
return fmt.Errorf("booleans must be major type 7")
1940
+
}
1941
+
switch extra {
1942
+
case 20:
1943
+
t.IsDefaultRef = false
1944
+
case 21:
1945
+
t.IsDefaultRef = true
1946
+
default:
1947
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
1948
+
}
1949
+
// t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct)
1950
+
case "langBreakdown":
1951
+
1952
+
{
1953
+
1954
+
b, err := cr.ReadByte()
1955
+
if err != nil {
1956
+
return err
1957
+
}
1958
+
if b != cbg.CborNull[0] {
1959
+
if err := cr.UnreadByte(); err != nil {
1960
+
return err
1961
+
}
1962
+
t.LangBreakdown = new(GitRefUpdate_LangBreakdown)
1963
+
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
1964
+
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
1965
+
}
1966
+
}
1967
+
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
+
}
1980
1980
func (t *GraphFollow) MarshalCBOR(w io.Writer) error {
1981
1981
if t == nil {
1982
1982
_, err := w.Write(cbg.CborNull)
···
2141
2141
2142
2142
return nil
2143
2143
}
2144
+
func (t *Knot) MarshalCBOR(w io.Writer) error {
2145
+
if t == nil {
2146
+
_, err := w.Write(cbg.CborNull)
2147
+
return err
2148
+
}
2149
+
2150
+
cw := cbg.NewCborWriter(w)
2151
+
2152
+
if _, err := cw.Write([]byte{162}); err != nil {
2153
+
return err
2154
+
}
2155
+
2156
+
// t.LexiconTypeID (string) (string)
2157
+
if len("$type") > 1000000 {
2158
+
return xerrors.Errorf("Value in field \"$type\" was too long")
2159
+
}
2160
+
2161
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2162
+
return err
2163
+
}
2164
+
if _, err := cw.WriteString(string("$type")); err != nil {
2165
+
return err
2166
+
}
2167
+
2168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil {
2169
+
return err
2170
+
}
2171
+
if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil {
2172
+
return err
2173
+
}
2174
+
2175
+
// t.CreatedAt (string) (string)
2176
+
if len("createdAt") > 1000000 {
2177
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2178
+
}
2179
+
2180
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2181
+
return err
2182
+
}
2183
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2184
+
return err
2185
+
}
2186
+
2187
+
if len(t.CreatedAt) > 1000000 {
2188
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2189
+
}
2190
+
2191
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2192
+
return err
2193
+
}
2194
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2195
+
return err
2196
+
}
2197
+
return nil
2198
+
}
2199
+
2200
+
func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) {
2201
+
*t = Knot{}
2202
+
2203
+
cr := cbg.NewCborReader(r)
2204
+
2205
+
maj, extra, err := cr.ReadHeader()
2206
+
if err != nil {
2207
+
return err
2208
+
}
2209
+
defer func() {
2210
+
if err == io.EOF {
2211
+
err = io.ErrUnexpectedEOF
2212
+
}
2213
+
}()
2214
+
2215
+
if maj != cbg.MajMap {
2216
+
return fmt.Errorf("cbor input should be of type map")
2217
+
}
2218
+
2219
+
if extra > cbg.MaxLength {
2220
+
return fmt.Errorf("Knot: map struct too large (%d)", extra)
2221
+
}
2222
+
2223
+
n := extra
2224
+
2225
+
nameBuf := make([]byte, 9)
2226
+
for i := uint64(0); i < n; i++ {
2227
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2228
+
if err != nil {
2229
+
return err
2230
+
}
2231
+
2232
+
if !ok {
2233
+
// Field doesn't exist on this type, so ignore it
2234
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2235
+
return err
2236
+
}
2237
+
continue
2238
+
}
2239
+
2240
+
switch string(nameBuf[:nameLen]) {
2241
+
// t.LexiconTypeID (string) (string)
2242
+
case "$type":
2243
+
2244
+
{
2245
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2246
+
if err != nil {
2247
+
return err
2248
+
}
2249
+
2250
+
t.LexiconTypeID = string(sval)
2251
+
}
2252
+
// t.CreatedAt (string) (string)
2253
+
case "createdAt":
2254
+
2255
+
{
2256
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2257
+
if err != nil {
2258
+
return err
2259
+
}
2260
+
2261
+
t.CreatedAt = string(sval)
2262
+
}
2263
+
2264
+
default:
2265
+
// Field doesn't exist on this type, so ignore it
2266
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2267
+
return err
2268
+
}
2269
+
}
2270
+
}
2271
+
2272
+
return nil
2273
+
}
2144
2274
func (t *KnotMember) MarshalCBOR(w io.Writer) error {
2145
2275
if t == nil {
2146
2276
_, err := w.Write(cbg.CborNull)
···
2716
2846
t.Submodules = true
2717
2847
default:
2718
2848
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
2719
-
}
2720
-
2721
-
default:
2722
-
// Field doesn't exist on this type, so ignore it
2723
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2724
-
return err
2725
-
}
2726
-
}
2727
-
}
2728
-
2729
-
return nil
2730
-
}
2731
-
func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error {
2732
-
if t == nil {
2733
-
_, err := w.Write(cbg.CborNull)
2734
-
return err
2735
-
}
2736
-
2737
-
cw := cbg.NewCborWriter(w)
2738
-
2739
-
if _, err := cw.Write([]byte{162}); err != nil {
2740
-
return err
2741
-
}
2742
-
2743
-
// t.Packages ([]string) (slice)
2744
-
if len("packages") > 1000000 {
2745
-
return xerrors.Errorf("Value in field \"packages\" was too long")
2746
-
}
2747
-
2748
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil {
2749
-
return err
2750
-
}
2751
-
if _, err := cw.WriteString(string("packages")); err != nil {
2752
-
return err
2753
-
}
2754
-
2755
-
if len(t.Packages) > 8192 {
2756
-
return xerrors.Errorf("Slice value in field t.Packages was too long")
2757
-
}
2758
-
2759
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil {
2760
-
return err
2761
-
}
2762
-
for _, v := range t.Packages {
2763
-
if len(v) > 1000000 {
2764
-
return xerrors.Errorf("Value in field v was too long")
2765
-
}
2766
-
2767
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2768
-
return err
2769
-
}
2770
-
if _, err := cw.WriteString(string(v)); err != nil {
2771
-
return err
2772
-
}
2773
-
2774
-
}
2775
-
2776
-
// t.Registry (string) (string)
2777
-
if len("registry") > 1000000 {
2778
-
return xerrors.Errorf("Value in field \"registry\" was too long")
2779
-
}
2780
-
2781
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil {
2782
-
return err
2783
-
}
2784
-
if _, err := cw.WriteString(string("registry")); err != nil {
2785
-
return err
2786
-
}
2787
-
2788
-
if len(t.Registry) > 1000000 {
2789
-
return xerrors.Errorf("Value in field t.Registry was too long")
2790
-
}
2791
-
2792
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil {
2793
-
return err
2794
-
}
2795
-
if _, err := cw.WriteString(string(t.Registry)); err != nil {
2796
-
return err
2797
-
}
2798
-
return nil
2799
-
}
2800
-
2801
-
func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) {
2802
-
*t = Pipeline_Dependency{}
2803
-
2804
-
cr := cbg.NewCborReader(r)
2805
-
2806
-
maj, extra, err := cr.ReadHeader()
2807
-
if err != nil {
2808
-
return err
2809
-
}
2810
-
defer func() {
2811
-
if err == io.EOF {
2812
-
err = io.ErrUnexpectedEOF
2813
-
}
2814
-
}()
2815
-
2816
-
if maj != cbg.MajMap {
2817
-
return fmt.Errorf("cbor input should be of type map")
2818
-
}
2819
-
2820
-
if extra > cbg.MaxLength {
2821
-
return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra)
2822
-
}
2823
-
2824
-
n := extra
2825
-
2826
-
nameBuf := make([]byte, 8)
2827
-
for i := uint64(0); i < n; i++ {
2828
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2829
-
if err != nil {
2830
-
return err
2831
-
}
2832
-
2833
-
if !ok {
2834
-
// Field doesn't exist on this type, so ignore it
2835
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2836
-
return err
2837
-
}
2838
-
continue
2839
-
}
2840
-
2841
-
switch string(nameBuf[:nameLen]) {
2842
-
// t.Packages ([]string) (slice)
2843
-
case "packages":
2844
-
2845
-
maj, extra, err = cr.ReadHeader()
2846
-
if err != nil {
2847
-
return err
2848
-
}
2849
-
2850
-
if extra > 8192 {
2851
-
return fmt.Errorf("t.Packages: array too large (%d)", extra)
2852
-
}
2853
-
2854
-
if maj != cbg.MajArray {
2855
-
return fmt.Errorf("expected cbor array")
2856
-
}
2857
-
2858
-
if extra > 0 {
2859
-
t.Packages = make([]string, extra)
2860
-
}
2861
-
2862
-
for i := 0; i < int(extra); i++ {
2863
-
{
2864
-
var maj byte
2865
-
var extra uint64
2866
-
var err error
2867
-
_ = maj
2868
-
_ = extra
2869
-
_ = err
2870
-
2871
-
{
2872
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2873
-
if err != nil {
2874
-
return err
2875
-
}
2876
-
2877
-
t.Packages[i] = string(sval)
2878
-
}
2879
-
2880
-
}
2881
-
}
2882
-
// t.Registry (string) (string)
2883
-
case "registry":
2884
-
2885
-
{
2886
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2887
-
if err != nil {
2888
-
return err
2889
-
}
2890
-
2891
-
t.Registry = string(sval)
2892
2849
}
2893
2850
2894
2851
default:
···
3916
3873
3917
3874
return nil
3918
3875
}
3919
-
func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error {
3920
-
if t == nil {
3921
-
_, err := w.Write(cbg.CborNull)
3922
-
return err
3923
-
}
3924
-
3925
-
cw := cbg.NewCborWriter(w)
3926
-
fieldCount := 3
3927
-
3928
-
if t.Environment == nil {
3929
-
fieldCount--
3930
-
}
3931
-
3932
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3933
-
return err
3934
-
}
3935
-
3936
-
// t.Name (string) (string)
3937
-
if len("name") > 1000000 {
3938
-
return xerrors.Errorf("Value in field \"name\" was too long")
3939
-
}
3940
-
3941
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
3942
-
return err
3943
-
}
3944
-
if _, err := cw.WriteString(string("name")); err != nil {
3945
-
return err
3946
-
}
3947
-
3948
-
if len(t.Name) > 1000000 {
3949
-
return xerrors.Errorf("Value in field t.Name was too long")
3950
-
}
3951
-
3952
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
3953
-
return err
3954
-
}
3955
-
if _, err := cw.WriteString(string(t.Name)); err != nil {
3956
-
return err
3957
-
}
3958
-
3959
-
// t.Command (string) (string)
3960
-
if len("command") > 1000000 {
3961
-
return xerrors.Errorf("Value in field \"command\" was too long")
3962
-
}
3963
-
3964
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil {
3965
-
return err
3966
-
}
3967
-
if _, err := cw.WriteString(string("command")); err != nil {
3968
-
return err
3969
-
}
3970
-
3971
-
if len(t.Command) > 1000000 {
3972
-
return xerrors.Errorf("Value in field t.Command was too long")
3973
-
}
3974
-
3975
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil {
3976
-
return err
3977
-
}
3978
-
if _, err := cw.WriteString(string(t.Command)); err != nil {
3979
-
return err
3980
-
}
3981
-
3982
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3983
-
if t.Environment != nil {
3984
-
3985
-
if len("environment") > 1000000 {
3986
-
return xerrors.Errorf("Value in field \"environment\" was too long")
3987
-
}
3988
-
3989
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
3990
-
return err
3991
-
}
3992
-
if _, err := cw.WriteString(string("environment")); err != nil {
3993
-
return err
3994
-
}
3995
-
3996
-
if len(t.Environment) > 8192 {
3997
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
3998
-
}
3999
-
4000
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
4001
-
return err
4002
-
}
4003
-
for _, v := range t.Environment {
4004
-
if err := v.MarshalCBOR(cw); err != nil {
4005
-
return err
4006
-
}
4007
-
4008
-
}
4009
-
}
4010
-
return nil
4011
-
}
4012
-
4013
-
func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) {
4014
-
*t = Pipeline_Step{}
4015
-
4016
-
cr := cbg.NewCborReader(r)
4017
-
4018
-
maj, extra, err := cr.ReadHeader()
4019
-
if err != nil {
4020
-
return err
4021
-
}
4022
-
defer func() {
4023
-
if err == io.EOF {
4024
-
err = io.ErrUnexpectedEOF
4025
-
}
4026
-
}()
4027
-
4028
-
if maj != cbg.MajMap {
4029
-
return fmt.Errorf("cbor input should be of type map")
4030
-
}
4031
-
4032
-
if extra > cbg.MaxLength {
4033
-
return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra)
4034
-
}
4035
-
4036
-
n := extra
4037
-
4038
-
nameBuf := make([]byte, 11)
4039
-
for i := uint64(0); i < n; i++ {
4040
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4041
-
if err != nil {
4042
-
return err
4043
-
}
4044
-
4045
-
if !ok {
4046
-
// Field doesn't exist on this type, so ignore it
4047
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
4048
-
return err
4049
-
}
4050
-
continue
4051
-
}
4052
-
4053
-
switch string(nameBuf[:nameLen]) {
4054
-
// t.Name (string) (string)
4055
-
case "name":
4056
-
4057
-
{
4058
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4059
-
if err != nil {
4060
-
return err
4061
-
}
4062
-
4063
-
t.Name = string(sval)
4064
-
}
4065
-
// t.Command (string) (string)
4066
-
case "command":
4067
-
4068
-
{
4069
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4070
-
if err != nil {
4071
-
return err
4072
-
}
4073
-
4074
-
t.Command = string(sval)
4075
-
}
4076
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4077
-
case "environment":
4078
-
4079
-
maj, extra, err = cr.ReadHeader()
4080
-
if err != nil {
4081
-
return err
4082
-
}
4083
-
4084
-
if extra > 8192 {
4085
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
4086
-
}
4087
-
4088
-
if maj != cbg.MajArray {
4089
-
return fmt.Errorf("expected cbor array")
4090
-
}
4091
-
4092
-
if extra > 0 {
4093
-
t.Environment = make([]*Pipeline_Pair, extra)
4094
-
}
4095
-
4096
-
for i := 0; i < int(extra); i++ {
4097
-
{
4098
-
var maj byte
4099
-
var extra uint64
4100
-
var err error
4101
-
_ = maj
4102
-
_ = extra
4103
-
_ = err
4104
-
4105
-
{
4106
-
4107
-
b, err := cr.ReadByte()
4108
-
if err != nil {
4109
-
return err
4110
-
}
4111
-
if b != cbg.CborNull[0] {
4112
-
if err := cr.UnreadByte(); err != nil {
4113
-
return err
4114
-
}
4115
-
t.Environment[i] = new(Pipeline_Pair)
4116
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
4117
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
4118
-
}
4119
-
}
4120
-
4121
-
}
4122
-
4123
-
}
4124
-
}
4125
-
4126
-
default:
4127
-
// Field doesn't exist on this type, so ignore it
4128
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
4129
-
return err
4130
-
}
4131
-
}
4132
-
}
4133
-
4134
-
return nil
4135
-
}
4136
3876
func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error {
4137
3877
if t == nil {
4138
3878
_, err := w.Write(cbg.CborNull)
···
4609
4349
4610
4350
cw := cbg.NewCborWriter(w)
4611
4351
4612
-
if _, err := cw.Write([]byte{165}); err != nil {
4352
+
if _, err := cw.Write([]byte{164}); err != nil {
4353
+
return err
4354
+
}
4355
+
4356
+
// t.Raw (string) (string)
4357
+
if len("raw") > 1000000 {
4358
+
return xerrors.Errorf("Value in field \"raw\" was too long")
4359
+
}
4360
+
4361
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil {
4362
+
return err
4363
+
}
4364
+
if _, err := cw.WriteString(string("raw")); err != nil {
4365
+
return err
4366
+
}
4367
+
4368
+
if len(t.Raw) > 1000000 {
4369
+
return xerrors.Errorf("Value in field t.Raw was too long")
4370
+
}
4371
+
4372
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil {
4373
+
return err
4374
+
}
4375
+
if _, err := cw.WriteString(string(t.Raw)); err != nil {
4613
4376
return err
4614
4377
}
4615
4378
···
4652
4415
return err
4653
4416
}
4654
4417
4655
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
4656
-
if len("steps") > 1000000 {
4657
-
return xerrors.Errorf("Value in field \"steps\" was too long")
4658
-
}
4659
-
4660
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil {
4661
-
return err
4662
-
}
4663
-
if _, err := cw.WriteString(string("steps")); err != nil {
4664
-
return err
4665
-
}
4666
-
4667
-
if len(t.Steps) > 8192 {
4668
-
return xerrors.Errorf("Slice value in field t.Steps was too long")
4669
-
}
4670
-
4671
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil {
4672
-
return err
4673
-
}
4674
-
for _, v := range t.Steps {
4675
-
if err := v.MarshalCBOR(cw); err != nil {
4676
-
return err
4677
-
}
4678
-
4679
-
}
4680
-
4681
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4682
-
if len("environment") > 1000000 {
4683
-
return xerrors.Errorf("Value in field \"environment\" was too long")
4418
+
// t.Engine (string) (string)
4419
+
if len("engine") > 1000000 {
4420
+
return xerrors.Errorf("Value in field \"engine\" was too long")
4684
4421
}
4685
4422
4686
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
4423
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil {
4687
4424
return err
4688
4425
}
4689
-
if _, err := cw.WriteString(string("environment")); err != nil {
4690
-
return err
4691
-
}
4692
-
4693
-
if len(t.Environment) > 8192 {
4694
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
4695
-
}
4696
-
4697
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
4426
+
if _, err := cw.WriteString(string("engine")); err != nil {
4698
4427
return err
4699
4428
}
4700
-
for _, v := range t.Environment {
4701
-
if err := v.MarshalCBOR(cw); err != nil {
4702
-
return err
4703
-
}
4704
4429
4705
-
}
4706
-
4707
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
4708
-
if len("dependencies") > 1000000 {
4709
-
return xerrors.Errorf("Value in field \"dependencies\" was too long")
4430
+
if len(t.Engine) > 1000000 {
4431
+
return xerrors.Errorf("Value in field t.Engine was too long")
4710
4432
}
4711
4433
4712
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil {
4713
-
return err
4714
-
}
4715
-
if _, err := cw.WriteString(string("dependencies")); err != nil {
4434
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil {
4716
4435
return err
4717
4436
}
4718
-
4719
-
if len(t.Dependencies) > 8192 {
4720
-
return xerrors.Errorf("Slice value in field t.Dependencies was too long")
4721
-
}
4722
-
4723
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil {
4437
+
if _, err := cw.WriteString(string(t.Engine)); err != nil {
4724
4438
return err
4725
-
}
4726
-
for _, v := range t.Dependencies {
4727
-
if err := v.MarshalCBOR(cw); err != nil {
4728
-
return err
4729
-
}
4730
-
4731
4439
}
4732
4440
return nil
4733
4441
}
···
4757
4465
4758
4466
n := extra
4759
4467
4760
-
nameBuf := make([]byte, 12)
4468
+
nameBuf := make([]byte, 6)
4761
4469
for i := uint64(0); i < n; i++ {
4762
4470
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4763
4471
if err != nil {
···
4773
4481
}
4774
4482
4775
4483
switch string(nameBuf[:nameLen]) {
4776
-
// t.Name (string) (string)
4484
+
// t.Raw (string) (string)
4485
+
case "raw":
4486
+
4487
+
{
4488
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4489
+
if err != nil {
4490
+
return err
4491
+
}
4492
+
4493
+
t.Raw = string(sval)
4494
+
}
4495
+
// t.Name (string) (string)
4777
4496
case "name":
4778
4497
4779
4498
{
···
4804
4523
}
4805
4524
4806
4525
}
4807
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
4808
-
case "steps":
4809
-
4810
-
maj, extra, err = cr.ReadHeader()
4811
-
if err != nil {
4812
-
return err
4813
-
}
4814
-
4815
-
if extra > 8192 {
4816
-
return fmt.Errorf("t.Steps: array too large (%d)", extra)
4817
-
}
4818
-
4819
-
if maj != cbg.MajArray {
4820
-
return fmt.Errorf("expected cbor array")
4821
-
}
4822
-
4823
-
if extra > 0 {
4824
-
t.Steps = make([]*Pipeline_Step, extra)
4825
-
}
4826
-
4827
-
for i := 0; i < int(extra); i++ {
4828
-
{
4829
-
var maj byte
4830
-
var extra uint64
4831
-
var err error
4832
-
_ = maj
4833
-
_ = extra
4834
-
_ = err
4835
-
4836
-
{
4837
-
4838
-
b, err := cr.ReadByte()
4839
-
if err != nil {
4840
-
return err
4841
-
}
4842
-
if b != cbg.CborNull[0] {
4843
-
if err := cr.UnreadByte(); err != nil {
4844
-
return err
4845
-
}
4846
-
t.Steps[i] = new(Pipeline_Step)
4847
-
if err := t.Steps[i].UnmarshalCBOR(cr); err != nil {
4848
-
return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err)
4849
-
}
4850
-
}
4851
-
4852
-
}
4853
-
4854
-
}
4855
-
}
4856
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4857
-
case "environment":
4858
-
4859
-
maj, extra, err = cr.ReadHeader()
4860
-
if err != nil {
4861
-
return err
4862
-
}
4863
-
4864
-
if extra > 8192 {
4865
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
4866
-
}
4867
-
4868
-
if maj != cbg.MajArray {
4869
-
return fmt.Errorf("expected cbor array")
4870
-
}
4871
-
4872
-
if extra > 0 {
4873
-
t.Environment = make([]*Pipeline_Pair, extra)
4874
-
}
4875
-
4876
-
for i := 0; i < int(extra); i++ {
4877
-
{
4878
-
var maj byte
4879
-
var extra uint64
4880
-
var err error
4881
-
_ = maj
4882
-
_ = extra
4883
-
_ = err
4884
-
4885
-
{
4886
-
4887
-
b, err := cr.ReadByte()
4888
-
if err != nil {
4889
-
return err
4890
-
}
4891
-
if b != cbg.CborNull[0] {
4892
-
if err := cr.UnreadByte(); err != nil {
4893
-
return err
4894
-
}
4895
-
t.Environment[i] = new(Pipeline_Pair)
4896
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
4897
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
4898
-
}
4899
-
}
4900
-
4901
-
}
4526
+
// t.Engine (string) (string)
4527
+
case "engine":
4902
4528
4529
+
{
4530
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4531
+
if err != nil {
4532
+
return err
4903
4533
}
4904
-
}
4905
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
4906
-
case "dependencies":
4907
4534
4908
-
maj, extra, err = cr.ReadHeader()
4909
-
if err != nil {
4910
-
return err
4911
-
}
4912
-
4913
-
if extra > 8192 {
4914
-
return fmt.Errorf("t.Dependencies: array too large (%d)", extra)
4915
-
}
4916
-
4917
-
if maj != cbg.MajArray {
4918
-
return fmt.Errorf("expected cbor array")
4919
-
}
4920
-
4921
-
if extra > 0 {
4922
-
t.Dependencies = make([]*Pipeline_Dependency, extra)
4923
-
}
4924
-
4925
-
for i := 0; i < int(extra); i++ {
4926
-
{
4927
-
var maj byte
4928
-
var extra uint64
4929
-
var err error
4930
-
_ = maj
4931
-
_ = extra
4932
-
_ = err
4933
-
4934
-
{
4935
-
4936
-
b, err := cr.ReadByte()
4937
-
if err != nil {
4938
-
return err
4939
-
}
4940
-
if b != cbg.CborNull[0] {
4941
-
if err := cr.UnreadByte(); err != nil {
4942
-
return err
4943
-
}
4944
-
t.Dependencies[i] = new(Pipeline_Dependency)
4945
-
if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil {
4946
-
return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err)
4947
-
}
4948
-
}
4949
-
4950
-
}
4951
-
4952
-
}
4535
+
t.Engine = string(sval)
4953
4536
}
4954
4537
4955
4538
default:
···
6059
5642
}
6060
5643
6061
5644
cw := cbg.NewCborWriter(w)
6062
-
fieldCount := 7
5645
+
fieldCount := 5
6063
5646
6064
5647
if t.Body == nil {
6065
5648
fieldCount--
···
6143
5726
return err
6144
5727
}
6145
5728
6146
-
// t.Owner (string) (string)
6147
-
if len("owner") > 1000000 {
6148
-
return xerrors.Errorf("Value in field \"owner\" was too long")
6149
-
}
6150
-
6151
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
6152
-
return err
6153
-
}
6154
-
if _, err := cw.WriteString(string("owner")); err != nil {
6155
-
return err
6156
-
}
6157
-
6158
-
if len(t.Owner) > 1000000 {
6159
-
return xerrors.Errorf("Value in field t.Owner was too long")
6160
-
}
6161
-
6162
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
6163
-
return err
6164
-
}
6165
-
if _, err := cw.WriteString(string(t.Owner)); err != nil {
6166
-
return err
6167
-
}
6168
-
6169
5729
// t.Title (string) (string)
6170
5730
if len("title") > 1000000 {
6171
5731
return xerrors.Errorf("Value in field \"title\" was too long")
···
6189
5749
return err
6190
5750
}
6191
5751
6192
-
// t.IssueId (int64) (int64)
6193
-
if len("issueId") > 1000000 {
6194
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
6195
-
}
6196
-
6197
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
6198
-
return err
6199
-
}
6200
-
if _, err := cw.WriteString(string("issueId")); err != nil {
6201
-
return err
6202
-
}
6203
-
6204
-
if t.IssueId >= 0 {
6205
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
6206
-
return err
6207
-
}
6208
-
} else {
6209
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
6210
-
return err
6211
-
}
6212
-
}
6213
-
6214
5752
// t.CreatedAt (string) (string)
6215
5753
if len("createdAt") > 1000000 {
6216
5754
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6320
5858
6321
5859
t.LexiconTypeID = string(sval)
6322
5860
}
6323
-
// t.Owner (string) (string)
6324
-
case "owner":
6325
-
6326
-
{
6327
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6328
-
if err != nil {
6329
-
return err
6330
-
}
6331
-
6332
-
t.Owner = string(sval)
6333
-
}
6334
5861
// t.Title (string) (string)
6335
5862
case "title":
6336
5863
···
6342
5869
6343
5870
t.Title = string(sval)
6344
5871
}
6345
-
// t.IssueId (int64) (int64)
6346
-
case "issueId":
6347
-
{
6348
-
maj, extra, err := cr.ReadHeader()
6349
-
if err != nil {
6350
-
return err
6351
-
}
6352
-
var extraI int64
6353
-
switch maj {
6354
-
case cbg.MajUnsignedInt:
6355
-
extraI = int64(extra)
6356
-
if extraI < 0 {
6357
-
return fmt.Errorf("int64 positive overflow")
6358
-
}
6359
-
case cbg.MajNegativeInt:
6360
-
extraI = int64(extra)
6361
-
if extraI < 0 {
6362
-
return fmt.Errorf("int64 negative overflow")
6363
-
}
6364
-
extraI = -1 - extraI
6365
-
default:
6366
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6367
-
}
6368
-
6369
-
t.IssueId = int64(extraI)
6370
-
}
6371
5872
// t.CreatedAt (string) (string)
6372
5873
case "createdAt":
6373
5874
···
6397
5898
}
6398
5899
6399
5900
cw := cbg.NewCborWriter(w)
6400
-
fieldCount := 7
6401
-
6402
-
if t.CommentId == nil {
6403
-
fieldCount--
6404
-
}
5901
+
fieldCount := 6
6405
5902
6406
5903
if t.Owner == nil {
6407
5904
fieldCount--
···
6544
6041
}
6545
6042
}
6546
6043
6547
-
// t.CommentId (int64) (int64)
6548
-
if t.CommentId != nil {
6549
-
6550
-
if len("commentId") > 1000000 {
6551
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
6552
-
}
6553
-
6554
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
6555
-
return err
6556
-
}
6557
-
if _, err := cw.WriteString(string("commentId")); err != nil {
6558
-
return err
6559
-
}
6560
-
6561
-
if t.CommentId == nil {
6562
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6563
-
return err
6564
-
}
6565
-
} else {
6566
-
if *t.CommentId >= 0 {
6567
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
6568
-
return err
6569
-
}
6570
-
} else {
6571
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
6572
-
return err
6573
-
}
6574
-
}
6575
-
}
6576
-
6577
-
}
6578
-
6579
6044
// t.CreatedAt (string) (string)
6580
6045
if len("createdAt") > 1000000 {
6581
6046
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6717
6182
t.Owner = (*string)(&sval)
6718
6183
}
6719
6184
}
6720
-
// t.CommentId (int64) (int64)
6721
-
case "commentId":
6722
-
{
6723
-
6724
-
b, err := cr.ReadByte()
6725
-
if err != nil {
6726
-
return err
6727
-
}
6728
-
if b != cbg.CborNull[0] {
6729
-
if err := cr.UnreadByte(); err != nil {
6730
-
return err
6731
-
}
6732
-
maj, extra, err := cr.ReadHeader()
6733
-
if err != nil {
6734
-
return err
6735
-
}
6736
-
var extraI int64
6737
-
switch maj {
6738
-
case cbg.MajUnsignedInt:
6739
-
extraI = int64(extra)
6740
-
if extraI < 0 {
6741
-
return fmt.Errorf("int64 positive overflow")
6742
-
}
6743
-
case cbg.MajNegativeInt:
6744
-
extraI = int64(extra)
6745
-
if extraI < 0 {
6746
-
return fmt.Errorf("int64 negative overflow")
6747
-
}
6748
-
extraI = -1 - extraI
6749
-
default:
6750
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6751
-
}
6752
-
6753
-
t.CommentId = (*int64)(&extraI)
6754
-
}
6755
-
}
6756
6185
// t.CreatedAt (string) (string)
6757
6186
case "createdAt":
6758
6187
···
6946
6375
}
6947
6376
6948
6377
cw := cbg.NewCborWriter(w)
6949
-
fieldCount := 9
6378
+
fieldCount := 7
6950
6379
6951
6380
if t.Body == nil {
6952
6381
fieldCount--
···
7057
6486
return err
7058
6487
}
7059
6488
7060
-
// t.PullId (int64) (int64)
7061
-
if len("pullId") > 1000000 {
7062
-
return xerrors.Errorf("Value in field \"pullId\" was too long")
7063
-
}
7064
-
7065
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil {
7066
-
return err
7067
-
}
7068
-
if _, err := cw.WriteString(string("pullId")); err != nil {
7069
-
return err
7070
-
}
7071
-
7072
-
if t.PullId >= 0 {
7073
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil {
7074
-
return err
7075
-
}
7076
-
} else {
7077
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil {
7078
-
return err
7079
-
}
7080
-
}
7081
-
7082
6489
// t.Source (tangled.RepoPull_Source) (struct)
7083
6490
if t.Source != nil {
7084
6491
···
7098
6505
}
7099
6506
}
7100
6507
7101
-
// t.CreatedAt (string) (string)
7102
-
if len("createdAt") > 1000000 {
7103
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
6508
+
// t.Target (tangled.RepoPull_Target) (struct)
6509
+
if len("target") > 1000000 {
6510
+
return xerrors.Errorf("Value in field \"target\" was too long")
7104
6511
}
7105
6512
7106
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
6513
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil {
7107
6514
return err
7108
6515
}
7109
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
6516
+
if _, err := cw.WriteString(string("target")); err != nil {
7110
6517
return err
7111
6518
}
7112
6519
7113
-
if len(t.CreatedAt) > 1000000 {
7114
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
7115
-
}
7116
-
7117
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
7118
-
return err
7119
-
}
7120
-
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
6520
+
if err := t.Target.MarshalCBOR(cw); err != nil {
7121
6521
return err
7122
6522
}
7123
6523
7124
-
// t.TargetRepo (string) (string)
7125
-
if len("targetRepo") > 1000000 {
7126
-
return xerrors.Errorf("Value in field \"targetRepo\" was too long")
7127
-
}
7128
-
7129
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil {
7130
-
return err
7131
-
}
7132
-
if _, err := cw.WriteString(string("targetRepo")); err != nil {
7133
-
return err
7134
-
}
7135
-
7136
-
if len(t.TargetRepo) > 1000000 {
7137
-
return xerrors.Errorf("Value in field t.TargetRepo was too long")
7138
-
}
7139
-
7140
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil {
7141
-
return err
7142
-
}
7143
-
if _, err := cw.WriteString(string(t.TargetRepo)); err != nil {
7144
-
return err
7145
-
}
7146
-
7147
-
// t.TargetBranch (string) (string)
7148
-
if len("targetBranch") > 1000000 {
7149
-
return xerrors.Errorf("Value in field \"targetBranch\" was too long")
6524
+
// t.CreatedAt (string) (string)
6525
+
if len("createdAt") > 1000000 {
6526
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
7150
6527
}
7151
6528
7152
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil {
6529
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
7153
6530
return err
7154
6531
}
7155
-
if _, err := cw.WriteString(string("targetBranch")); err != nil {
6532
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
7156
6533
return err
7157
6534
}
7158
6535
7159
-
if len(t.TargetBranch) > 1000000 {
7160
-
return xerrors.Errorf("Value in field t.TargetBranch was too long")
6536
+
if len(t.CreatedAt) > 1000000 {
6537
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
7161
6538
}
7162
6539
7163
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil {
6540
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
7164
6541
return err
7165
6542
}
7166
-
if _, err := cw.WriteString(string(t.TargetBranch)); err != nil {
6543
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7167
6544
return err
7168
6545
}
7169
6546
return nil
···
7194
6571
7195
6572
n := extra
7196
6573
7197
-
nameBuf := make([]byte, 12)
6574
+
nameBuf := make([]byte, 9)
7198
6575
for i := uint64(0); i < n; i++ {
7199
6576
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7200
6577
if err != nil {
···
7264
6641
7265
6642
t.Title = string(sval)
7266
6643
}
7267
-
// t.PullId (int64) (int64)
7268
-
case "pullId":
7269
-
{
7270
-
maj, extra, err := cr.ReadHeader()
7271
-
if err != nil {
7272
-
return err
7273
-
}
7274
-
var extraI int64
7275
-
switch maj {
7276
-
case cbg.MajUnsignedInt:
7277
-
extraI = int64(extra)
7278
-
if extraI < 0 {
7279
-
return fmt.Errorf("int64 positive overflow")
7280
-
}
7281
-
case cbg.MajNegativeInt:
7282
-
extraI = int64(extra)
7283
-
if extraI < 0 {
7284
-
return fmt.Errorf("int64 negative overflow")
7285
-
}
7286
-
extraI = -1 - extraI
7287
-
default:
7288
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
7289
-
}
7290
-
7291
-
t.PullId = int64(extraI)
7292
-
}
7293
6644
// t.Source (tangled.RepoPull_Source) (struct)
7294
6645
case "source":
7295
6646
···
7310
6661
}
7311
6662
7312
6663
}
7313
-
// t.CreatedAt (string) (string)
7314
-
case "createdAt":
6664
+
// t.Target (tangled.RepoPull_Target) (struct)
6665
+
case "target":
7315
6666
7316
6667
{
7317
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6668
+
6669
+
b, err := cr.ReadByte()
7318
6670
if err != nil {
7319
6671
return err
7320
6672
}
7321
-
7322
-
t.CreatedAt = string(sval)
7323
-
}
7324
-
// t.TargetRepo (string) (string)
7325
-
case "targetRepo":
7326
-
7327
-
{
7328
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7329
-
if err != nil {
7330
-
return err
6673
+
if b != cbg.CborNull[0] {
6674
+
if err := cr.UnreadByte(); err != nil {
6675
+
return err
6676
+
}
6677
+
t.Target = new(RepoPull_Target)
6678
+
if err := t.Target.UnmarshalCBOR(cr); err != nil {
6679
+
return xerrors.Errorf("unmarshaling t.Target pointer: %w", err)
6680
+
}
7331
6681
}
7332
6682
7333
-
t.TargetRepo = string(sval)
7334
6683
}
7335
-
// t.TargetBranch (string) (string)
7336
-
case "targetBranch":
6684
+
// t.CreatedAt (string) (string)
6685
+
case "createdAt":
7337
6686
7338
6687
{
7339
6688
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
7341
6690
return err
7342
6691
}
7343
6692
7344
-
t.TargetBranch = string(sval)
6693
+
t.CreatedAt = string(sval)
7345
6694
}
7346
6695
7347
6696
default:
···
7361
6710
}
7362
6711
7363
6712
cw := cbg.NewCborWriter(w)
7364
-
fieldCount := 7
7365
6713
7366
-
if t.CommentId == nil {
7367
-
fieldCount--
7368
-
}
7369
-
7370
-
if t.Owner == nil {
7371
-
fieldCount--
7372
-
}
7373
-
7374
-
if t.Repo == nil {
7375
-
fieldCount--
7376
-
}
7377
-
7378
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
6714
+
if _, err := cw.Write([]byte{164}); err != nil {
7379
6715
return err
7380
6716
}
7381
6717
···
7425
6761
return err
7426
6762
}
7427
6763
7428
-
// t.Repo (string) (string)
7429
-
if t.Repo != nil {
7430
-
7431
-
if len("repo") > 1000000 {
7432
-
return xerrors.Errorf("Value in field \"repo\" was too long")
7433
-
}
7434
-
7435
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
7436
-
return err
7437
-
}
7438
-
if _, err := cw.WriteString(string("repo")); err != nil {
7439
-
return err
7440
-
}
7441
-
7442
-
if t.Repo == nil {
7443
-
if _, err := cw.Write(cbg.CborNull); err != nil {
7444
-
return err
7445
-
}
7446
-
} else {
7447
-
if len(*t.Repo) > 1000000 {
7448
-
return xerrors.Errorf("Value in field t.Repo was too long")
7449
-
}
7450
-
7451
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
7452
-
return err
7453
-
}
7454
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
7455
-
return err
7456
-
}
7457
-
}
7458
-
}
7459
-
7460
6764
// t.LexiconTypeID (string) (string)
7461
6765
if len("$type") > 1000000 {
7462
6766
return xerrors.Errorf("Value in field \"$type\" was too long")
···
7476
6780
return err
7477
6781
}
7478
6782
7479
-
// t.Owner (string) (string)
7480
-
if t.Owner != nil {
7481
-
7482
-
if len("owner") > 1000000 {
7483
-
return xerrors.Errorf("Value in field \"owner\" was too long")
7484
-
}
7485
-
7486
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
7487
-
return err
7488
-
}
7489
-
if _, err := cw.WriteString(string("owner")); err != nil {
7490
-
return err
7491
-
}
7492
-
7493
-
if t.Owner == nil {
7494
-
if _, err := cw.Write(cbg.CborNull); err != nil {
7495
-
return err
7496
-
}
7497
-
} else {
7498
-
if len(*t.Owner) > 1000000 {
7499
-
return xerrors.Errorf("Value in field t.Owner was too long")
7500
-
}
7501
-
7502
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
7503
-
return err
7504
-
}
7505
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
7506
-
return err
7507
-
}
7508
-
}
7509
-
}
7510
-
7511
-
// t.CommentId (int64) (int64)
7512
-
if t.CommentId != nil {
7513
-
7514
-
if len("commentId") > 1000000 {
7515
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
7516
-
}
7517
-
7518
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
7519
-
return err
7520
-
}
7521
-
if _, err := cw.WriteString(string("commentId")); err != nil {
7522
-
return err
7523
-
}
7524
-
7525
-
if t.CommentId == nil {
7526
-
if _, err := cw.Write(cbg.CborNull); err != nil {
7527
-
return err
7528
-
}
7529
-
} else {
7530
-
if *t.CommentId >= 0 {
7531
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
7532
-
return err
7533
-
}
7534
-
} else {
7535
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
7536
-
return err
7537
-
}
7538
-
}
7539
-
}
7540
-
7541
-
}
7542
-
7543
6783
// t.CreatedAt (string) (string)
7544
6784
if len("createdAt") > 1000000 {
7545
6785
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7628
6868
7629
6869
t.Pull = string(sval)
7630
6870
}
7631
-
// t.Repo (string) (string)
7632
-
case "repo":
7633
-
7634
-
{
7635
-
b, err := cr.ReadByte()
7636
-
if err != nil {
7637
-
return err
7638
-
}
7639
-
if b != cbg.CborNull[0] {
7640
-
if err := cr.UnreadByte(); err != nil {
7641
-
return err
7642
-
}
7643
-
7644
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7645
-
if err != nil {
7646
-
return err
7647
-
}
7648
-
7649
-
t.Repo = (*string)(&sval)
7650
-
}
7651
-
}
7652
6871
// t.LexiconTypeID (string) (string)
7653
6872
case "$type":
7654
6873
···
7659
6878
}
7660
6879
7661
6880
t.LexiconTypeID = string(sval)
7662
-
}
7663
-
// t.Owner (string) (string)
7664
-
case "owner":
7665
-
7666
-
{
7667
-
b, err := cr.ReadByte()
7668
-
if err != nil {
7669
-
return err
7670
-
}
7671
-
if b != cbg.CborNull[0] {
7672
-
if err := cr.UnreadByte(); err != nil {
7673
-
return err
7674
-
}
7675
-
7676
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7677
-
if err != nil {
7678
-
return err
7679
-
}
7680
-
7681
-
t.Owner = (*string)(&sval)
7682
-
}
7683
-
}
7684
-
// t.CommentId (int64) (int64)
7685
-
case "commentId":
7686
-
{
7687
-
7688
-
b, err := cr.ReadByte()
7689
-
if err != nil {
7690
-
return err
7691
-
}
7692
-
if b != cbg.CborNull[0] {
7693
-
if err := cr.UnreadByte(); err != nil {
7694
-
return err
7695
-
}
7696
-
maj, extra, err := cr.ReadHeader()
7697
-
if err != nil {
7698
-
return err
7699
-
}
7700
-
var extraI int64
7701
-
switch maj {
7702
-
case cbg.MajUnsignedInt:
7703
-
extraI = int64(extra)
7704
-
if extraI < 0 {
7705
-
return fmt.Errorf("int64 positive overflow")
7706
-
}
7707
-
case cbg.MajNegativeInt:
7708
-
extraI = int64(extra)
7709
-
if extraI < 0 {
7710
-
return fmt.Errorf("int64 negative overflow")
7711
-
}
7712
-
extraI = -1 - extraI
7713
-
default:
7714
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
7715
-
}
7716
-
7717
-
t.CommentId = (*int64)(&extraI)
7718
-
}
7719
6881
}
7720
6882
// t.CreatedAt (string) (string)
7721
6883
case "createdAt":
···
8083
7245
}
8084
7246
8085
7247
t.Status = string(sval)
7248
+
}
7249
+
7250
+
default:
7251
+
// Field doesn't exist on this type, so ignore it
7252
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
7253
+
return err
7254
+
}
7255
+
}
7256
+
}
7257
+
7258
+
return nil
7259
+
}
7260
+
func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error {
7261
+
if t == nil {
7262
+
_, err := w.Write(cbg.CborNull)
7263
+
return err
7264
+
}
7265
+
7266
+
cw := cbg.NewCborWriter(w)
7267
+
7268
+
if _, err := cw.Write([]byte{162}); err != nil {
7269
+
return err
7270
+
}
7271
+
7272
+
// t.Repo (string) (string)
7273
+
if len("repo") > 1000000 {
7274
+
return xerrors.Errorf("Value in field \"repo\" was too long")
7275
+
}
7276
+
7277
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
7278
+
return err
7279
+
}
7280
+
if _, err := cw.WriteString(string("repo")); err != nil {
7281
+
return err
7282
+
}
7283
+
7284
+
if len(t.Repo) > 1000000 {
7285
+
return xerrors.Errorf("Value in field t.Repo was too long")
7286
+
}
7287
+
7288
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
7289
+
return err
7290
+
}
7291
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
7292
+
return err
7293
+
}
7294
+
7295
+
// t.Branch (string) (string)
7296
+
if len("branch") > 1000000 {
7297
+
return xerrors.Errorf("Value in field \"branch\" was too long")
7298
+
}
7299
+
7300
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil {
7301
+
return err
7302
+
}
7303
+
if _, err := cw.WriteString(string("branch")); err != nil {
7304
+
return err
7305
+
}
7306
+
7307
+
if len(t.Branch) > 1000000 {
7308
+
return xerrors.Errorf("Value in field t.Branch was too long")
7309
+
}
7310
+
7311
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil {
7312
+
return err
7313
+
}
7314
+
if _, err := cw.WriteString(string(t.Branch)); err != nil {
7315
+
return err
7316
+
}
7317
+
return nil
7318
+
}
7319
+
7320
+
func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) {
7321
+
*t = RepoPull_Target{}
7322
+
7323
+
cr := cbg.NewCborReader(r)
7324
+
7325
+
maj, extra, err := cr.ReadHeader()
7326
+
if err != nil {
7327
+
return err
7328
+
}
7329
+
defer func() {
7330
+
if err == io.EOF {
7331
+
err = io.ErrUnexpectedEOF
7332
+
}
7333
+
}()
7334
+
7335
+
if maj != cbg.MajMap {
7336
+
return fmt.Errorf("cbor input should be of type map")
7337
+
}
7338
+
7339
+
if extra > cbg.MaxLength {
7340
+
return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra)
7341
+
}
7342
+
7343
+
n := extra
7344
+
7345
+
nameBuf := make([]byte, 6)
7346
+
for i := uint64(0); i < n; i++ {
7347
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7348
+
if err != nil {
7349
+
return err
7350
+
}
7351
+
7352
+
if !ok {
7353
+
// Field doesn't exist on this type, so ignore it
7354
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
7355
+
return err
7356
+
}
7357
+
continue
7358
+
}
7359
+
7360
+
switch string(nameBuf[:nameLen]) {
7361
+
// t.Repo (string) (string)
7362
+
case "repo":
7363
+
7364
+
{
7365
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7366
+
if err != nil {
7367
+
return err
7368
+
}
7369
+
7370
+
t.Repo = string(sval)
7371
+
}
7372
+
// t.Branch (string) (string)
7373
+
case "branch":
7374
+
7375
+
{
7376
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7377
+
if err != nil {
7378
+
return err
7379
+
}
7380
+
7381
+
t.Branch = string(sval)
8086
7382
}
8087
7383
8088
7384
default:
+19
-15
api/tangled/gitrefUpdate.go
+19
-15
api/tangled/gitrefUpdate.go
···
33
33
RepoName string `json:"repoName" cborgen:"repoName"`
34
34
}
35
35
36
-
type GitRefUpdate_Meta struct {
37
-
CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"`
38
-
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
39
-
LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
36
+
// GitRefUpdate_CommitCountBreakdown is a "commitCountBreakdown" in the sh.tangled.git.refUpdate schema.
37
+
type GitRefUpdate_CommitCountBreakdown struct {
38
+
ByEmail []*GitRefUpdate_IndividualEmailCommitCount `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"`
40
39
}
41
40
42
-
type GitRefUpdate_Meta_CommitCount struct {
43
-
ByEmail []*GitRefUpdate_Meta_CommitCount_ByEmail_Elem `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"`
44
-
}
45
-
46
-
type GitRefUpdate_Meta_CommitCount_ByEmail_Elem struct {
41
+
// GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema.
42
+
type GitRefUpdate_IndividualEmailCommitCount struct {
47
43
Count int64 `json:"count" cborgen:"count"`
48
44
Email string `json:"email" cborgen:"email"`
49
45
}
50
46
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 {
47
+
// GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema.
48
+
type GitRefUpdate_IndividualLanguageSize struct {
57
49
Lang string `json:"lang" cborgen:"lang"`
58
50
Size int64 `json:"size" cborgen:"size"`
59
51
}
52
+
53
+
// GitRefUpdate_LangBreakdown is a "langBreakdown" in the sh.tangled.git.refUpdate schema.
54
+
type GitRefUpdate_LangBreakdown struct {
55
+
Inputs []*GitRefUpdate_IndividualLanguageSize `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
56
+
}
57
+
58
+
// GitRefUpdate_Meta is a "meta" in the sh.tangled.git.refUpdate schema.
59
+
type GitRefUpdate_Meta struct {
60
+
CommitCount *GitRefUpdate_CommitCountBreakdown `json:"commitCount" cborgen:"commitCount"`
61
+
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
62
+
LangBreakdown *GitRefUpdate_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
63
+
}
-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"`
+4
-7
api/tangled/pullcomment.go
+4
-7
api/tangled/pullcomment.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPullComment
19
19
type RepoPullComment struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
-
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
25
-
Pull string `json:"pull" cborgen:"pull"`
26
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Pull string `json:"pull" cborgen:"pull"`
27
24
}
+34
api/tangled/repocreate.go
+34
api/tangled/repocreate.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.create
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoCreateNSID = "sh.tangled.repo.create"
15
+
)
16
+
17
+
// RepoCreate_Input is the input argument to a sh.tangled.repo.create call.
18
+
type RepoCreate_Input struct {
19
+
// defaultBranch: Default branch to push to
20
+
DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
21
+
// rkey: Rkey of the repository record
22
+
Rkey string `json:"rkey" cborgen:"rkey"`
23
+
// source: A source URL to clone from, populate this when forking or importing a repository.
24
+
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
25
+
}
26
+
27
+
// RepoCreate calls the XRPC method "sh.tangled.repo.create".
28
+
func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error {
29
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil {
30
+
return err
31
+
}
32
+
33
+
return nil
34
+
}
+34
api/tangled/repodelete.go
+34
api/tangled/repodelete.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.delete
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoDeleteNSID = "sh.tangled.repo.delete"
15
+
)
16
+
17
+
// RepoDelete_Input is the input argument to a sh.tangled.repo.delete call.
18
+
type RepoDelete_Input struct {
19
+
// did: DID of the repository owner
20
+
Did string `json:"did" cborgen:"did"`
21
+
// name: Name of the repository to delete
22
+
Name string `json:"name" cborgen:"name"`
23
+
// rkey: Rkey of the repository record
24
+
Rkey string `json:"rkey" cborgen:"rkey"`
25
+
}
26
+
27
+
// RepoDelete calls the XRPC method "sh.tangled.repo.delete".
28
+
func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error {
29
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil {
30
+
return err
31
+
}
32
+
33
+
return nil
34
+
}
+45
api/tangled/repoforkStatus.go
+45
api/tangled/repoforkStatus.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.forkStatus
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoForkStatusNSID = "sh.tangled.repo.forkStatus"
15
+
)
16
+
17
+
// RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call.
18
+
type RepoForkStatus_Input struct {
19
+
// branch: Branch to check status for
20
+
Branch string `json:"branch" cborgen:"branch"`
21
+
// did: DID of the fork owner
22
+
Did string `json:"did" cborgen:"did"`
23
+
// hiddenRef: Hidden ref to use for comparison
24
+
HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"`
25
+
// name: Name of the forked repository
26
+
Name string `json:"name" cborgen:"name"`
27
+
// source: Source repository URL
28
+
Source string `json:"source" cborgen:"source"`
29
+
}
30
+
31
+
// RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call.
32
+
type RepoForkStatus_Output struct {
33
+
// status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch
34
+
Status int64 `json:"status" cborgen:"status"`
35
+
}
36
+
37
+
// RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus".
38
+
func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) {
39
+
var out RepoForkStatus_Output
40
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil {
41
+
return nil, err
42
+
}
43
+
44
+
return &out, nil
45
+
}
+36
api/tangled/repoforkSync.go
+36
api/tangled/repoforkSync.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.forkSync
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoForkSyncNSID = "sh.tangled.repo.forkSync"
15
+
)
16
+
17
+
// RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call.
18
+
type RepoForkSync_Input struct {
19
+
// branch: Branch to sync
20
+
Branch string `json:"branch" cborgen:"branch"`
21
+
// did: DID of the fork owner
22
+
Did string `json:"did" cborgen:"did"`
23
+
// name: Name of the forked repository
24
+
Name string `json:"name" cborgen:"name"`
25
+
// source: AT-URI of the source repository
26
+
Source string `json:"source" cborgen:"source"`
27
+
}
28
+
29
+
// RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync".
30
+
func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error {
31
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil {
32
+
return err
33
+
}
34
+
35
+
return nil
36
+
}
-2
api/tangled/repoissue.go
-2
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
-
Owner string `json:"owner" cborgen:"owner"`
25
23
Repo string `json:"repo" cborgen:"repo"`
26
24
Title string `json:"title" cborgen:"title"`
27
25
}
+44
api/tangled/repomerge.go
+44
api/tangled/repomerge.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.merge
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoMergeNSID = "sh.tangled.repo.merge"
15
+
)
16
+
17
+
// RepoMerge_Input is the input argument to a sh.tangled.repo.merge call.
18
+
type RepoMerge_Input struct {
19
+
// authorEmail: Author email for the merge commit
20
+
AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"`
21
+
// authorName: Author name for the merge commit
22
+
AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"`
23
+
// branch: Target branch to merge into
24
+
Branch string `json:"branch" cborgen:"branch"`
25
+
// commitBody: Additional commit message body
26
+
CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"`
27
+
// commitMessage: Merge commit message
28
+
CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"`
29
+
// did: DID of the repository owner
30
+
Did string `json:"did" cborgen:"did"`
31
+
// name: Name of the repository
32
+
Name string `json:"name" cborgen:"name"`
33
+
// patch: Patch content to merge
34
+
Patch string `json:"patch" cborgen:"patch"`
35
+
}
36
+
37
+
// RepoMerge calls the XRPC method "sh.tangled.repo.merge".
38
+
func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error {
39
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil {
40
+
return err
41
+
}
42
+
43
+
return nil
44
+
}
+57
api/tangled/repomergeCheck.go
+57
api/tangled/repomergeCheck.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.mergeCheck
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck"
15
+
)
16
+
17
+
// RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema.
18
+
type RepoMergeCheck_ConflictInfo struct {
19
+
// filename: Name of the conflicted file
20
+
Filename string `json:"filename" cborgen:"filename"`
21
+
// reason: Reason for the conflict
22
+
Reason string `json:"reason" cborgen:"reason"`
23
+
}
24
+
25
+
// RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call.
26
+
type RepoMergeCheck_Input struct {
27
+
// branch: Target branch to merge into
28
+
Branch string `json:"branch" cborgen:"branch"`
29
+
// did: DID of the repository owner
30
+
Did string `json:"did" cborgen:"did"`
31
+
// name: Name of the repository
32
+
Name string `json:"name" cborgen:"name"`
33
+
// patch: Patch or pull request to check for merge conflicts
34
+
Patch string `json:"patch" cborgen:"patch"`
35
+
}
36
+
37
+
// RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call.
38
+
type RepoMergeCheck_Output struct {
39
+
// conflicts: List of files with merge conflicts
40
+
Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"`
41
+
// error: Error message if check failed
42
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
43
+
// is_conflicted: Whether the merge has conflicts
44
+
Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"`
45
+
// message: Additional message about the merge check
46
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
47
+
}
48
+
49
+
// RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck".
50
+
func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) {
51
+
var out RepoMergeCheck_Output
52
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil {
53
+
return nil, err
54
+
}
55
+
56
+
return &out, nil
57
+
}
+7
-3
api/tangled/repopull.go
+7
-3
api/tangled/repopull.go
···
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
23
Patch string `json:"patch" cborgen:"patch"`
24
-
PullId int64 `json:"pullId" cborgen:"pullId"`
25
24
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
26
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
27
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
25
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
28
26
Title string `json:"title" cborgen:"title"`
29
27
}
30
28
···
34
32
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
35
33
Sha string `json:"sha" cborgen:"sha"`
36
34
}
35
+
36
+
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
37
+
type RepoPull_Target struct {
38
+
Branch string `json:"branch" cborgen:"branch"`
39
+
Repo string `json:"repo" cborgen:"repo"`
40
+
}
+22
api/tangled/tangledknot.go
+22
api/tangled/tangledknot.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
KnotNSID = "sh.tangled.knot"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.knot", &Knot{})
17
+
} //
18
+
// RECORDTYPE: Knot
19
+
type Knot struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
}
+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
}
+1
appview/cache/session/store.go
+1
appview/cache/session/store.go
+4
-1
appview/config/config.go
+4
-1
appview/config/config.go
···
17
17
Dev bool `env:"DEV, default=false"`
18
18
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
19
20
-
// temporarily, to add users to default spindle
20
+
// temporarily, to add users to default knot and spindle
21
21
AppPassword string `env:"APP_PASSWORD"`
22
+
23
+
// uhhhh this is because knot1 is under icy's did
24
+
TmpAltAppPassword string `env:"ALT_APP_PASSWORD"`
22
25
}
23
26
24
27
type OAuthConfig struct {
+71
-23
appview/db/db.go
+71
-23
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, "&"))
31
39
if err != nil {
32
40
return nil, err
33
41
}
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;
42
+
43
+
ctx := context.Background()
43
44
45
+
conn, err := db.Conn(ctx)
46
+
if err != nil {
47
+
return nil, err
48
+
}
49
+
defer conn.Close()
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,
···
462
470
id integer primary key autoincrement,
463
471
name text unique
464
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);
465
477
`)
466
478
if err != nil {
467
479
return nil, err
468
480
}
469
481
470
482
// run migrations
471
-
runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error {
483
+
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
472
484
tx.Exec(`
473
485
alter table repos add column description text check (length(description) <= 200);
474
486
`)
475
487
return nil
476
488
})
477
489
478
-
runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
490
+
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
479
491
// add unconstrained column
480
492
_, err := tx.Exec(`
481
493
alter table public_keys
···
498
510
return nil
499
511
})
500
512
501
-
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
513
+
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
502
514
_, err := tx.Exec(`
503
515
alter table comments drop column comment_at;
504
516
alter table comments add column rkey text;
···
506
518
return err
507
519
})
508
520
509
-
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 {
510
522
_, err := tx.Exec(`
511
523
alter table comments add column deleted text; -- timestamp
512
524
alter table comments add column edited text; -- timestamp
···
514
526
return err
515
527
})
516
528
517
-
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 {
518
530
_, err := tx.Exec(`
519
531
alter table pulls add column source_branch text;
520
532
alter table pulls add column source_repo_at text;
···
523
535
return err
524
536
})
525
537
526
-
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
538
+
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
527
539
_, err := tx.Exec(`
528
540
alter table repos add column source text;
529
541
`)
···
534
546
// NOTE: this cannot be done in a transaction, so it is run outside [0]
535
547
//
536
548
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
537
-
db.Exec("pragma foreign_keys = off;")
538
-
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 {
539
551
_, err := tx.Exec(`
540
552
create table pulls_new (
541
553
-- identifiers
···
590
602
`)
591
603
return err
592
604
})
593
-
db.Exec("pragma foreign_keys = on;")
605
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
594
606
595
607
// run migrations
596
-
runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error {
608
+
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
597
609
tx.Exec(`
598
610
alter table repos add column spindle text;
599
611
`)
600
612
return nil
601
613
})
602
614
615
+
// drop all knot secrets, add unique constraint to knots
616
+
//
617
+
// knots will henceforth use service auth for signed requests
618
+
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
619
+
_, err := tx.Exec(`
620
+
create table registrations_new (
621
+
id integer primary key autoincrement,
622
+
domain text not null,
623
+
did text not null,
624
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
625
+
registered text,
626
+
read_only integer not null default 0,
627
+
unique(domain, did)
628
+
);
629
+
630
+
insert into registrations_new (id, domain, did, created, registered, read_only)
631
+
select id, domain, did, created, registered, 1 from registrations
632
+
where registered is not null;
633
+
634
+
drop table registrations;
635
+
alter table registrations_new rename to registrations;
636
+
`)
637
+
return err
638
+
})
639
+
603
640
// recreate and add rkey + created columns with default constraint
604
-
runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error {
641
+
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
605
642
// create new table
606
643
// - repo_at instead of repo integer
607
644
// - rkey field
···
655
692
return err
656
693
})
657
694
695
+
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
696
+
_, err := tx.Exec(`
697
+
alter table issues add column rkey text not null default '';
698
+
699
+
-- get last url section from issue_at and save to rkey column
700
+
update issues
701
+
set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), '');
702
+
`)
703
+
return err
704
+
})
705
+
658
706
return &DB{db}, nil
659
707
}
660
708
661
709
type migrationFn = func(*sql.Tx) error
662
710
663
-
func runMigration(d *sql.DB, name string, migrationFn migrationFn) error {
664
-
tx, err := d.Begin()
711
+
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
712
+
tx, err := c.BeginTx(context.Background(), nil)
665
713
if err != nil {
666
714
return err
667
715
}
+145
-42
appview/db/follow.go
+145
-42
appview/db/follow.go
···
1
1
package db
2
2
3
3
import (
4
+
"fmt"
4
5
"log"
6
+
"strings"
5
7
"time"
6
8
)
7
9
···
53
55
return err
54
56
}
55
57
56
-
func GetFollowerFollowing(e Execer, did string) (int, int, error) {
57
-
followers, following := 0, 0
58
+
type FollowStats struct {
59
+
Followers int64
60
+
Following int64
61
+
}
62
+
63
+
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
64
+
var followers, following int64
58
65
err := e.QueryRow(
59
-
`SELECT
66
+
`SELECT
60
67
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
61
68
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
62
69
FROM follows;`, did, did).Scan(&followers, &following)
63
70
if err != nil {
64
-
return 0, 0, err
71
+
return FollowStats{}, err
65
72
}
66
-
return followers, following, nil
73
+
return FollowStats{
74
+
Followers: followers,
75
+
Following: following,
76
+
}, nil
67
77
}
68
78
69
-
type FollowStatus int
79
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
80
+
if len(dids) == 0 {
81
+
return nil, nil
82
+
}
70
83
71
-
const (
72
-
IsNotFollowing FollowStatus = iota
73
-
IsFollowing
74
-
IsSelf
75
-
)
84
+
placeholders := make([]string, len(dids))
85
+
for i := range placeholders {
86
+
placeholders[i] = "?"
87
+
}
88
+
placeholderStr := strings.Join(placeholders, ",")
76
89
77
-
func (s FollowStatus) String() string {
78
-
switch s {
79
-
case IsNotFollowing:
80
-
return "IsNotFollowing"
81
-
case IsFollowing:
82
-
return "IsFollowing"
83
-
case IsSelf:
84
-
return "IsSelf"
85
-
default:
86
-
return "IsNotFollowing"
90
+
args := make([]any, len(dids)*2)
91
+
for i, did := range dids {
92
+
args[i] = did
93
+
args[i+len(dids)] = did
87
94
}
88
-
}
95
+
96
+
query := fmt.Sprintf(`
97
+
select
98
+
coalesce(f.did, g.did) as did,
99
+
coalesce(f.followers, 0) as followers,
100
+
coalesce(g.following, 0) as following
101
+
from (
102
+
select subject_did as did, count(*) as followers
103
+
from follows
104
+
where subject_did in (%s)
105
+
group by subject_did
106
+
) f
107
+
full outer join (
108
+
select user_did as did, count(*) as following
109
+
from follows
110
+
where user_did in (%s)
111
+
group by user_did
112
+
) g on f.did = g.did`,
113
+
placeholderStr, placeholderStr)
114
+
115
+
result := make(map[string]FollowStats)
116
+
117
+
rows, err := e.Query(query, args...)
118
+
if err != nil {
119
+
return nil, err
120
+
}
121
+
defer rows.Close()
122
+
123
+
for rows.Next() {
124
+
var did string
125
+
var followers, following int64
126
+
if err := rows.Scan(&did, &followers, &following); err != nil {
127
+
return nil, err
128
+
}
129
+
result[did] = FollowStats{
130
+
Followers: followers,
131
+
Following: following,
132
+
}
133
+
}
89
134
90
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
91
-
if userDid == subjectDid {
92
-
return IsSelf
93
-
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
94
-
return IsNotFollowing
95
-
} else {
96
-
return IsFollowing
135
+
for _, did := range dids {
136
+
if _, exists := result[did]; !exists {
137
+
result[did] = FollowStats{
138
+
Followers: 0,
139
+
Following: 0,
140
+
}
141
+
}
97
142
}
143
+
144
+
return result, nil
98
145
}
99
146
100
-
func GetAllFollows(e Execer, limit int) ([]Follow, error) {
147
+
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
101
148
var follows []Follow
102
149
103
-
rows, err := e.Query(`
104
-
select user_did, subject_did, followed_at, rkey
150
+
var conditions []string
151
+
var args []any
152
+
for _, filter := range filters {
153
+
conditions = append(conditions, filter.Condition())
154
+
args = append(args, filter.Arg()...)
155
+
}
156
+
157
+
whereClause := ""
158
+
if conditions != nil {
159
+
whereClause = " where " + strings.Join(conditions, " and ")
160
+
}
161
+
limitClause := ""
162
+
if limit > 0 {
163
+
limitClause = " limit ?"
164
+
args = append(args, limit)
165
+
}
166
+
167
+
query := fmt.Sprintf(
168
+
`select user_did, subject_did, followed_at, rkey
105
169
from follows
170
+
%s
106
171
order by followed_at desc
107
-
limit ?`, limit,
108
-
)
172
+
%s
173
+
`, whereClause, limitClause)
174
+
175
+
rows, err := e.Query(query, args...)
109
176
if err != nil {
110
177
return nil, err
111
178
}
112
-
defer rows.Close()
113
-
114
179
for rows.Next() {
115
180
var follow Follow
116
181
var followedAt string
117
-
if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil {
182
+
err := rows.Scan(
183
+
&follow.UserDid,
184
+
&follow.SubjectDid,
185
+
&followedAt,
186
+
&follow.Rkey,
187
+
)
188
+
if err != nil {
118
189
return nil, err
119
190
}
120
-
121
191
followedAtTime, err := time.Parse(time.RFC3339, followedAt)
122
192
if err != nil {
123
193
log.Println("unable to determine followed at time")
···
125
195
} else {
126
196
follow.FollowedAt = followedAtTime
127
197
}
128
-
129
198
follows = append(follows, follow)
130
199
}
200
+
return follows, nil
201
+
}
202
+
203
+
func GetFollowers(e Execer, did string) ([]Follow, error) {
204
+
return GetFollows(e, 0, FilterEq("subject_did", did))
205
+
}
131
206
132
-
if err := rows.Err(); err != nil {
133
-
return nil, err
207
+
func GetFollowing(e Execer, did string) ([]Follow, error) {
208
+
return GetFollows(e, 0, FilterEq("user_did", did))
209
+
}
210
+
211
+
type FollowStatus int
212
+
213
+
const (
214
+
IsNotFollowing FollowStatus = iota
215
+
IsFollowing
216
+
IsSelf
217
+
)
218
+
219
+
func (s FollowStatus) String() string {
220
+
switch s {
221
+
case IsNotFollowing:
222
+
return "IsNotFollowing"
223
+
case IsFollowing:
224
+
return "IsFollowing"
225
+
case IsSelf:
226
+
return "IsSelf"
227
+
default:
228
+
return "IsNotFollowing"
134
229
}
230
+
}
135
231
136
-
return follows, nil
232
+
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
233
+
if userDid == subjectDid {
234
+
return IsSelf
235
+
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
236
+
return IsNotFollowing
237
+
} else {
238
+
return IsFollowing
239
+
}
137
240
}
+208
-17
appview/db/issues.go
+208
-17
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
···
13
17
RepoAt syntax.ATURI
14
18
OwnerDid string
15
19
IssueId int
16
-
IssueAt string
20
+
Rkey string
17
21
Created time.Time
18
22
Title string
19
23
Body string
···
42
46
Edited *time.Time
43
47
}
44
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: did,
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
+
45
125
func NewIssue(tx *sql.Tx, issue *Issue) error {
46
126
defer tx.Rollback()
47
127
···
67
147
issue.IssueId = nextId
68
148
69
149
res, err := tx.Exec(`
70
-
insert into issues (repo_at, owner_did, issue_id, title, body)
71
-
values (?, ?, ?, ?, ?)
72
-
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
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)
73
153
if err != nil {
74
154
return err
75
155
}
···
87
167
return nil
88
168
}
89
169
90
-
func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error {
91
-
_, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId)
92
-
return err
93
-
}
94
-
95
170
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
96
171
var issueAt string
97
172
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
···
104
179
return ownerDid, err
105
180
}
106
181
107
-
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) {
108
183
var issues []Issue
109
184
openValue := 0
110
185
if isOpen {
···
117
192
select
118
193
i.id,
119
194
i.owner_did,
195
+
i.rkey,
120
196
i.issue_id,
121
197
i.created,
122
198
i.title,
···
136
212
select
137
213
id,
138
214
owner_did,
215
+
rkey,
139
216
issue_id,
140
217
created,
141
218
title,
142
219
body,
143
220
open,
144
221
comment_count
145
-
from
222
+
from
146
223
numbered_issue
147
-
where
224
+
where
148
225
row_num between ? and ?`,
149
226
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
150
227
if err != nil {
···
156
233
var issue Issue
157
234
var createdAt string
158
235
var metadata IssueMetadata
159
-
err := rows.Scan(&issue.ID, &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)
160
237
if err != nil {
161
238
return nil, err
162
239
}
···
178
255
return issues, nil
179
256
}
180
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
+
181
338
// timeframe here is directly passed into the sql query filter, and any
182
339
// timeframe in the past should be negative; e.g.: "-3 months"
183
340
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
···
187
344
`select
188
345
i.id,
189
346
i.owner_did,
347
+
i.rkey,
190
348
i.repo_at,
191
349
i.issue_id,
192
350
i.created,
···
219
377
err := rows.Scan(
220
378
&issue.ID,
221
379
&issue.OwnerDid,
380
+
&issue.Rkey,
222
381
&issue.RepoAt,
223
382
&issue.IssueId,
224
383
&issueCreatedAt,
···
262
421
}
263
422
264
423
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
265
-
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
424
+
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
266
425
row := e.QueryRow(query, repoAt, issueId)
267
426
268
427
var issue Issue
269
428
var createdAt string
270
-
err := row.Scan(&issue.ID, &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)
271
430
if err != nil {
272
431
return nil, err
273
432
}
···
282
441
}
283
442
284
443
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
285
-
query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
444
+
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
286
445
row := e.QueryRow(query, repoAt, issueId)
287
446
288
447
var issue Issue
289
448
var createdAt string
290
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
449
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
291
450
if err != nil {
292
451
return nil, nil, err
293
452
}
···
464
623
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
465
624
where repo_at = ? and issue_id = ? and comment_id = ?
466
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)
467
658
return err
468
659
}
469
660
-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;
+16
-7
appview/db/profile.go
+16
-7
appview/db/profile.go
···
22
22
ByMonth []ByMonth
23
23
}
24
24
25
+
func (p *ProfileTimeline) IsEmpty() bool {
26
+
if p == nil {
27
+
return true
28
+
}
29
+
30
+
for _, m := range p.ByMonth {
31
+
if !m.IsEmpty() {
32
+
return false
33
+
}
34
+
}
35
+
36
+
return true
37
+
}
38
+
25
39
type ByMonth struct {
26
40
RepoEvents []RepoEvent
27
41
IssueEvents IssueEvents
···
348
362
return tx.Commit()
349
363
}
350
364
351
-
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
365
+
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
352
366
var conditions []string
353
367
var args []any
354
368
for _, filter := range filters {
···
448
462
idxs[did] = idx + 1
449
463
}
450
464
451
-
var profiles []Profile
452
-
for _, p := range profileMap {
453
-
profiles = append(profiles, *p)
454
-
}
455
-
456
-
return profiles, nil
465
+
return profileMap, nil
457
466
}
458
467
459
468
func GetProfile(e Execer, did string) (*Profile, error) {
+31
-11
appview/db/pulls.go
+31
-11
appview/db/pulls.go
···
91
91
}
92
92
93
93
record := tangled.RepoPull{
94
-
Title: p.Title,
95
-
Body: &p.Body,
96
-
CreatedAt: p.Created.Format(time.RFC3339),
97
-
PullId: int64(p.PullId),
98
-
TargetRepo: p.RepoAt.String(),
99
-
TargetBranch: p.TargetBranch,
100
-
Patch: p.LatestPatch(),
101
-
Source: source,
94
+
Title: p.Title,
95
+
Body: &p.Body,
96
+
CreatedAt: p.Created.Format(time.RFC3339),
97
+
Target: &tangled.RepoPull_Target{
98
+
Repo: p.RepoAt.String(),
99
+
Branch: p.TargetBranch,
100
+
},
101
+
Patch: p.LatestPatch(),
102
+
Source: source,
102
103
}
103
104
return record
104
105
}
···
310
311
return pullId - 1, err
311
312
}
312
313
313
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
314
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
314
315
pulls := make(map[int]*Pull)
315
316
316
317
var conditions []string
···
324
325
if conditions != nil {
325
326
whereClause = " where " + strings.Join(conditions, " and ")
326
327
}
328
+
limitClause := ""
329
+
if limit != 0 {
330
+
limitClause = fmt.Sprintf(" limit %d ", limit)
331
+
}
327
332
328
333
query := fmt.Sprintf(`
329
334
select
···
344
349
from
345
350
pulls
346
351
%s
347
-
`, whereClause)
352
+
order by
353
+
created desc
354
+
%s
355
+
`, whereClause, limitClause)
348
356
349
357
rows, err := e.Query(query, args...)
350
358
if err != nil {
···
412
420
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
413
421
submissionsQuery := fmt.Sprintf(`
414
422
select
415
-
id, pull_id, round_number, patch, source_rev
423
+
id, pull_id, round_number, patch, created, source_rev
416
424
from
417
425
pull_submissions
418
426
where
···
438
446
for submissionsRows.Next() {
439
447
var s PullSubmission
440
448
var sourceRev sql.NullString
449
+
var createdAt string
441
450
err := submissionsRows.Scan(
442
451
&s.ID,
443
452
&s.PullId,
444
453
&s.RoundNumber,
445
454
&s.Patch,
455
+
&createdAt,
446
456
&sourceRev,
447
457
)
448
458
if err != nil {
449
459
return nil, err
450
460
}
451
461
462
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
463
+
if err != nil {
464
+
return nil, err
465
+
}
466
+
s.Created = createdTime
467
+
452
468
if sourceRev.Valid {
453
469
s.SourceRev = sourceRev.String
454
470
}
···
511
527
})
512
528
513
529
return orderedByPullId, nil
530
+
}
531
+
532
+
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
533
+
return GetPullsWithLimit(e, 0, filters...)
514
534
}
515
535
516
536
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+4
-4
appview/db/punchcard.go
+4
-4
appview/db/punchcard.go
···
29
29
Punches []Punch
30
30
}
31
31
32
-
func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) {
33
-
punchcard := Punchcard{}
32
+
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
+
punchcard := &Punchcard{}
34
34
now := time.Now()
35
35
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
36
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
···
63
63
64
64
rows, err := e.Query(query, args...)
65
65
if err != nil {
66
-
return punchcard, err
66
+
return nil, err
67
67
}
68
68
defer rows.Close()
69
69
···
72
72
var date string
73
73
var count sql.NullInt64
74
74
if err := rows.Scan(&date, &count); err != nil {
75
-
return punchcard, err
75
+
return nil, err
76
76
}
77
77
78
78
punch.Date, err = time.Parse(time.DateOnly, date)
+7
-7
appview/db/reaction.go
+7
-7
appview/db/reaction.go
···
11
11
12
12
const (
13
13
Like ReactionKind = "๐"
14
-
Unlike = "๐"
15
-
Laugh = "๐"
16
-
Celebration = "๐"
17
-
Confused = "๐ซค"
18
-
Heart = "โค๏ธ"
19
-
Rocket = "๐"
20
-
Eyes = "๐"
14
+
Unlike ReactionKind = "๐"
15
+
Laugh ReactionKind = "๐"
16
+
Celebration ReactionKind = "๐"
17
+
Confused ReactionKind = "๐ซค"
18
+
Heart ReactionKind = "โค๏ธ"
19
+
Rocket ReactionKind = "๐"
20
+
Eyes ReactionKind = "๐"
21
21
)
22
22
23
23
func (rk ReactionKind) String() string {
+89
-125
appview/db/registration.go
+89
-125
appview/db/registration.go
···
1
1
package db
2
2
3
3
import (
4
-
"crypto/rand"
5
4
"database/sql"
6
-
"encoding/hex"
7
5
"fmt"
8
-
"log"
6
+
"strings"
9
7
"time"
10
8
)
11
9
10
+
// Registration represents a knot registration. Knot would've been a better
11
+
// name but we're stuck with this for historical reasons.
12
12
type Registration struct {
13
13
Id int64
14
14
Domain string
15
15
ByDid string
16
16
Created *time.Time
17
17
Registered *time.Time
18
+
ReadOnly bool
18
19
}
19
20
20
21
func (r *Registration) Status() Status {
21
-
if r.Registered != nil {
22
+
if r.ReadOnly {
23
+
return ReadOnly
24
+
} else if r.Registered != nil {
22
25
return Registered
23
26
} else {
24
27
return Pending
25
28
}
26
29
}
27
30
31
+
func (r *Registration) IsRegistered() bool {
32
+
return r.Status() == Registered
33
+
}
34
+
35
+
func (r *Registration) IsReadOnly() bool {
36
+
return r.Status() == ReadOnly
37
+
}
38
+
39
+
func (r *Registration) IsPending() bool {
40
+
return r.Status() == Pending
41
+
}
42
+
28
43
type Status uint32
29
44
30
45
const (
31
46
Registered Status = iota
32
47
Pending
48
+
ReadOnly
33
49
)
34
50
35
-
// returns registered status, did of owner, error
36
-
func RegistrationsByDid(e Execer, did string) ([]Registration, error) {
51
+
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
37
52
var registrations []Registration
38
53
39
-
rows, err := e.Query(`
40
-
select id, domain, did, created, registered from registrations
41
-
where did = ?
42
-
`, did)
54
+
var conditions []string
55
+
var args []any
56
+
for _, filter := range filters {
57
+
conditions = append(conditions, filter.Condition())
58
+
args = append(args, filter.Arg()...)
59
+
}
60
+
61
+
whereClause := ""
62
+
if conditions != nil {
63
+
whereClause = " where " + strings.Join(conditions, " and ")
64
+
}
65
+
66
+
query := fmt.Sprintf(`
67
+
select id, domain, did, created, registered, read_only
68
+
from registrations
69
+
%s
70
+
order by created
71
+
`,
72
+
whereClause,
73
+
)
74
+
75
+
rows, err := e.Query(query, args...)
43
76
if err != nil {
44
77
return nil, err
45
78
}
46
79
47
80
for rows.Next() {
48
-
var createdAt *string
49
-
var registeredAt *string
50
-
var registration Registration
51
-
err = rows.Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
81
+
var createdAt string
82
+
var registeredAt sql.Null[string]
83
+
var readOnly int
84
+
var reg Registration
52
85
86
+
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &readOnly)
53
87
if err != nil {
54
-
log.Println(err)
55
-
} else {
56
-
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
57
-
var registeredAtTime *time.Time
58
-
if registeredAt != nil {
59
-
x, _ := time.Parse(time.RFC3339, *registeredAt)
60
-
registeredAtTime = &x
61
-
}
88
+
return nil, err
89
+
}
62
90
63
-
registration.Created = &createdAtTime
64
-
registration.Registered = registeredAtTime
65
-
registrations = append(registrations, registration)
91
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
92
+
reg.Created = &t
66
93
}
67
-
}
68
94
69
-
return registrations, nil
70
-
}
71
-
72
-
// returns registered status, did of owner, error
73
-
func RegistrationByDomain(e Execer, domain string) (*Registration, error) {
74
-
var createdAt *string
75
-
var registeredAt *string
76
-
var registration Registration
77
-
78
-
err := e.QueryRow(`
79
-
select id, domain, did, created, registered from registrations
80
-
where domain = ?
81
-
`, domain).Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
95
+
if registeredAt.Valid {
96
+
if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil {
97
+
reg.Registered = &t
98
+
}
99
+
}
82
100
83
-
if err != nil {
84
-
if err == sql.ErrNoRows {
85
-
return nil, nil
86
-
} else {
87
-
return nil, err
101
+
if readOnly != 0 {
102
+
reg.ReadOnly = true
88
103
}
89
-
}
90
104
91
-
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
92
-
var registeredAtTime *time.Time
93
-
if registeredAt != nil {
94
-
x, _ := time.Parse(time.RFC3339, *registeredAt)
95
-
registeredAtTime = &x
105
+
registrations = append(registrations, reg)
96
106
}
97
107
98
-
registration.Created = &createdAtTime
99
-
registration.Registered = registeredAtTime
100
-
101
-
return ®istration, nil
102
-
}
103
-
104
-
func genSecret() string {
105
-
key := make([]byte, 32)
106
-
rand.Read(key)
107
-
return hex.EncodeToString(key)
108
+
return registrations, nil
108
109
}
109
110
110
-
func GenerateRegistrationKey(e Execer, domain, did string) (string, error) {
111
-
// sanity check: does this domain already have a registration?
112
-
reg, err := RegistrationByDomain(e, domain)
113
-
if err != nil {
114
-
return "", err
115
-
}
116
-
117
-
// registration is open
118
-
if reg != nil {
119
-
switch reg.Status() {
120
-
case Registered:
121
-
// already registered by `owner`
122
-
return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid)
123
-
case Pending:
124
-
// TODO: be loud about this
125
-
log.Printf("%s registered by %s, status pending", domain, reg.ByDid)
126
-
}
111
+
func MarkRegistered(e Execer, filters ...filter) error {
112
+
var conditions []string
113
+
var args []any
114
+
for _, filter := range filters {
115
+
conditions = append(conditions, filter.Condition())
116
+
args = append(args, filter.Arg()...)
127
117
}
128
118
129
-
secret := genSecret()
130
-
131
-
_, err = e.Exec(`
132
-
insert into registrations (domain, did, secret)
133
-
values (?, ?, ?)
134
-
on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created
135
-
`, domain, did, secret)
136
-
137
-
if err != nil {
138
-
return "", err
119
+
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0"
120
+
if len(conditions) > 0 {
121
+
query += " where " + strings.Join(conditions, " and ")
139
122
}
140
123
141
-
return secret, nil
124
+
_, err := e.Exec(query, args...)
125
+
return err
142
126
}
143
127
144
-
func GetRegistrationKey(e Execer, domain string) (string, error) {
145
-
res := e.QueryRow(`select secret from registrations where domain = ?`, domain)
146
-
147
-
var secret string
148
-
err := res.Scan(&secret)
149
-
if err != nil || secret == "" {
150
-
return "", err
151
-
}
152
-
153
-
return secret, nil
128
+
func AddKnot(e Execer, domain, did string) error {
129
+
_, err := e.Exec(`
130
+
insert into registrations (domain, did)
131
+
values (?, ?)
132
+
`, domain, did)
133
+
return err
154
134
}
155
135
156
-
func GetCompletedRegistrations(e Execer) ([]string, error) {
157
-
rows, err := e.Query(`select domain from registrations where registered not null`)
158
-
if err != nil {
159
-
return nil, err
136
+
func DeleteKnot(e Execer, filters ...filter) error {
137
+
var conditions []string
138
+
var args []any
139
+
for _, filter := range filters {
140
+
conditions = append(conditions, filter.Condition())
141
+
args = append(args, filter.Arg()...)
160
142
}
161
143
162
-
var domains []string
163
-
for rows.Next() {
164
-
var domain string
165
-
err = rows.Scan(&domain)
166
-
167
-
if err != nil {
168
-
log.Println(err)
169
-
} else {
170
-
domains = append(domains, domain)
171
-
}
172
-
}
173
-
174
-
if err = rows.Err(); err != nil {
175
-
return nil, err
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
176
147
}
177
148
178
-
return domains, nil
179
-
}
149
+
query := fmt.Sprintf(`delete from registrations %s`, whereClause)
180
150
181
-
func Register(e Execer, domain string) error {
182
-
_, err := e.Exec(`
183
-
update registrations
184
-
set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
185
-
where domain = ?;
186
-
`, domain)
187
-
151
+
_, err := e.Exec(query, args...)
188
152
return err
189
153
}
+44
-81
appview/db/repos.go
+44
-81
appview/db/repos.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"errors"
5
6
"fmt"
6
7
"log"
7
8
"slices"
···
19
20
Knot string
20
21
Rkey string
21
22
Created time.Time
22
-
AtUri string
23
23
Description string
24
24
Spindle string
25
25
···
37
37
func (r Repo) DidSlashRepo() string {
38
38
p, _ := securejoin.SecureJoin(r.Did, r.Name)
39
39
return p
40
-
}
41
-
42
-
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
43
-
var repos []Repo
44
-
45
-
rows, err := e.Query(
46
-
`select did, name, knot, rkey, description, created, source
47
-
from repos
48
-
order by created desc
49
-
limit ?
50
-
`,
51
-
limit,
52
-
)
53
-
if err != nil {
54
-
return nil, err
55
-
}
56
-
defer rows.Close()
57
-
58
-
for rows.Next() {
59
-
var repo Repo
60
-
err := scanRepo(
61
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
62
-
)
63
-
if err != nil {
64
-
return nil, err
65
-
}
66
-
repos = append(repos, repo)
67
-
}
68
-
69
-
if err := rows.Err(); err != nil {
70
-
return nil, err
71
-
}
72
-
73
-
return repos, nil
74
40
}
75
41
76
42
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
···
311
277
312
278
slices.SortFunc(repos, func(a, b Repo) int {
313
279
if a.Created.After(b.Created) {
314
-
return 1
280
+
return -1
315
281
}
316
-
return -1
282
+
return 1
317
283
})
318
284
319
285
return repos, nil
320
286
}
321
287
288
+
func CountRepos(e Execer, filters ...filter) (int64, error) {
289
+
var conditions []string
290
+
var args []any
291
+
for _, filter := range filters {
292
+
conditions = append(conditions, filter.Condition())
293
+
args = append(args, filter.Arg()...)
294
+
}
295
+
296
+
whereClause := ""
297
+
if conditions != nil {
298
+
whereClause = " where " + strings.Join(conditions, " and ")
299
+
}
300
+
301
+
repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause)
302
+
var count int64
303
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
304
+
305
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
306
+
return 0, err
307
+
}
308
+
309
+
return count, nil
310
+
}
311
+
322
312
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
323
313
var repos []Repo
324
314
···
391
381
var description, spindle sql.NullString
392
382
393
383
row := e.QueryRow(`
394
-
select did, name, knot, created, at_uri, description, spindle
384
+
select did, name, knot, created, description, spindle, rkey
395
385
from repos
396
386
where did = ? and name = ?
397
387
`,
···
400
390
)
401
391
402
392
var createdAt string
403
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil {
393
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil {
404
394
return nil, err
405
395
}
406
396
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
421
411
var repo Repo
422
412
var nullableDescription sql.NullString
423
413
424
-
row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri)
414
+
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
425
415
426
416
var createdAt string
427
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil {
417
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
428
418
return nil, err
429
419
}
430
420
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
444
434
`insert into repos
445
435
(did, name, knot, rkey, at_uri, description, source)
446
436
values (?, ?, ?, ?, ?, ?, ?)`,
447
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
437
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
448
438
)
449
439
return err
450
440
}
···
467
457
var repos []Repo
468
458
469
459
rows, err := e.Query(
470
-
`select did, name, knot, rkey, description, created, at_uri, source
471
-
from repos
472
-
where did = ? and source is not null and source != ''
473
-
order by created desc`,
474
-
did,
460
+
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
461
+
from repos r
462
+
left join collaborators c on r.at_uri = c.repo_at
463
+
where (r.did = ? or c.subject_did = ?)
464
+
and r.source is not null
465
+
and r.source != ''
466
+
order by r.created desc`,
467
+
did, did,
475
468
)
476
469
if err != nil {
477
470
return nil, err
···
484
477
var nullableDescription sql.NullString
485
478
var nullableSource sql.NullString
486
479
487
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
480
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
488
481
if err != nil {
489
482
return nil, err
490
483
}
···
521
514
var nullableSource sql.NullString
522
515
523
516
row := e.QueryRow(
524
-
`select did, name, knot, rkey, description, created, at_uri, source
517
+
`select did, name, knot, rkey, description, created, source
525
518
from repos
526
519
where did = ? and name = ? and source is not null and source != ''`,
527
520
did, name,
528
521
)
529
522
530
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
523
+
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
531
524
if err != nil {
532
525
return nil, err
533
526
}
···
556
549
return err
557
550
}
558
551
559
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
552
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
560
553
_, err := e.Exec(
561
554
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
562
555
return err
···
568
561
IssueCount IssueCount
569
562
PullCount PullCount
570
563
}
571
-
572
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
573
-
var createdAt string
574
-
var nullableDescription sql.NullString
575
-
var nullableSource sql.NullString
576
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
577
-
return err
578
-
}
579
-
580
-
if nullableDescription.Valid {
581
-
*description = nullableDescription.String
582
-
} else {
583
-
*description = ""
584
-
}
585
-
586
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
587
-
if err != nil {
588
-
*created = time.Now()
589
-
} else {
590
-
*created = createdAtTime
591
-
}
592
-
593
-
if nullableSource.Valid {
594
-
*source = nullableSource.String
595
-
} else {
596
-
*source = ""
597
-
}
598
-
599
-
return nil
600
-
}
+100
-6
appview/db/star.go
+100
-6
appview/db/star.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
5
+
"errors"
4
6
"fmt"
5
7
"log"
6
8
"strings"
···
47
49
// Get a star record
48
50
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
49
51
query := `
50
-
select starred_by_did, repo_at, created, rkey
52
+
select starred_by_did, repo_at, created, rkey
51
53
from stars
52
54
where starred_by_did = ? and repo_at = ?`
53
55
row := e.QueryRow(query, starredByDid, repoAt)
···
119
121
}
120
122
121
123
repoQuery := fmt.Sprintf(
122
-
`select starred_by_did, repo_at, created, rkey
124
+
`select starred_by_did, repo_at, created, rkey
123
125
from stars
124
126
%s
125
127
order by created desc
···
183
185
return stars, nil
184
186
}
185
187
188
+
func CountStars(e Execer, filters ...filter) (int64, error) {
189
+
var conditions []string
190
+
var args []any
191
+
for _, filter := range filters {
192
+
conditions = append(conditions, filter.Condition())
193
+
args = append(args, filter.Arg()...)
194
+
}
195
+
196
+
whereClause := ""
197
+
if conditions != nil {
198
+
whereClause = " where " + strings.Join(conditions, " and ")
199
+
}
200
+
201
+
repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause)
202
+
var count int64
203
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
204
+
205
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
206
+
return 0, err
207
+
}
208
+
209
+
return count, nil
210
+
}
211
+
186
212
func GetAllStars(e Execer, limit int) ([]Star, error) {
187
213
var stars []Star
188
214
189
215
rows, err := e.Query(`
190
-
select
216
+
select
191
217
s.starred_by_did,
192
218
s.repo_at,
193
219
s.rkey,
···
196
222
r.name,
197
223
r.knot,
198
224
r.rkey,
199
-
r.created,
200
-
r.at_uri
225
+
r.created
201
226
from stars s
202
227
join repos r on s.repo_at = r.at_uri
203
228
`)
···
222
247
&repo.Knot,
223
248
&repo.Rkey,
224
249
&repoCreatedAt,
225
-
&repo.AtUri,
226
250
); err != nil {
227
251
return nil, err
228
252
}
···
246
270
247
271
return stars, nil
248
272
}
273
+
274
+
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
275
+
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
276
+
// first, get the top repo URIs by star count from the last week
277
+
query := `
278
+
with recent_starred_repos as (
279
+
select distinct repo_at
280
+
from stars
281
+
where created >= datetime('now', '-7 days')
282
+
),
283
+
repo_star_counts as (
284
+
select
285
+
s.repo_at,
286
+
count(*) as stars_gained_last_week
287
+
from stars s
288
+
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
289
+
where s.created >= datetime('now', '-7 days')
290
+
group by s.repo_at
291
+
)
292
+
select rsc.repo_at
293
+
from repo_star_counts rsc
294
+
order by rsc.stars_gained_last_week desc
295
+
limit 8
296
+
`
297
+
298
+
rows, err := e.Query(query)
299
+
if err != nil {
300
+
return nil, err
301
+
}
302
+
defer rows.Close()
303
+
304
+
var repoUris []string
305
+
for rows.Next() {
306
+
var repoUri string
307
+
err := rows.Scan(&repoUri)
308
+
if err != nil {
309
+
return nil, err
310
+
}
311
+
repoUris = append(repoUris, repoUri)
312
+
}
313
+
314
+
if err := rows.Err(); err != nil {
315
+
return nil, err
316
+
}
317
+
318
+
if len(repoUris) == 0 {
319
+
return []Repo{}, nil
320
+
}
321
+
322
+
// get full repo data
323
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
324
+
if err != nil {
325
+
return nil, err
326
+
}
327
+
328
+
// sort repos by the original trending order
329
+
repoMap := make(map[string]Repo)
330
+
for _, repo := range repos {
331
+
repoMap[repo.RepoAt().String()] = repo
332
+
}
333
+
334
+
orderedRepos := make([]Repo, 0, len(repoUris))
335
+
for _, uri := range repoUris {
336
+
if repo, exists := repoMap[uri]; exists {
337
+
orderedRepos = append(orderedRepos, repo)
338
+
}
339
+
}
340
+
341
+
return orderedRepos, nil
342
+
}
+36
-11
appview/db/strings.go
+36
-11
appview/db/strings.go
···
50
50
func (s String) Validate() error {
51
51
var err error
52
52
53
-
if !strings.Contains(s.Filename, ".") {
54
-
err = errors.Join(err, fmt.Errorf("missing filename extension"))
55
-
}
56
-
57
-
if strings.HasSuffix(s.Filename, ".") {
58
-
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59
-
}
60
-
61
53
if utf8.RuneCountInString(s.Filename) > 140 {
62
54
err = errors.Join(err, fmt.Errorf("filename too long"))
63
55
}
···
113
105
filename = excluded.filename,
114
106
description = excluded.description,
115
107
content = excluded.content,
116
-
edited = case
108
+
edited = case
117
109
when
118
110
strings.content != excluded.content
119
111
or strings.filename != excluded.filename
···
131
123
return err
132
124
}
133
125
134
-
func GetStrings(e Execer, filters ...filter) ([]String, error) {
126
+
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
135
127
var all []String
136
128
137
129
var conditions []string
···
146
138
whereClause = " where " + strings.Join(conditions, " and ")
147
139
}
148
140
141
+
limitClause := ""
142
+
if limit != 0 {
143
+
limitClause = fmt.Sprintf(" limit %d ", limit)
144
+
}
145
+
149
146
query := fmt.Sprintf(`select
150
147
did,
151
148
rkey,
···
154
151
content,
155
152
created,
156
153
edited
157
-
from strings %s`,
154
+
from strings
155
+
%s
156
+
order by created desc
157
+
%s`,
158
158
whereClause,
159
+
limitClause,
159
160
)
160
161
161
162
rows, err := e.Query(query, args...)
···
203
204
}
204
205
205
206
return all, nil
207
+
}
208
+
209
+
func CountStrings(e Execer, filters ...filter) (int64, 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
+
repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause)
223
+
var count int64
224
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
225
+
226
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
227
+
return 0, err
228
+
}
229
+
230
+
return count, nil
206
231
}
207
232
208
233
func DeleteString(e Execer, filters ...filter) error {
+6
-22
appview/db/timeline.go
+6
-22
appview/db/timeline.go
···
20
20
*FollowStats
21
21
}
22
22
23
-
type FollowStats struct {
24
-
Followers int
25
-
Following int
26
-
}
27
-
28
23
const Limit = 50
29
24
30
25
// TODO: this gathers heterogenous events from different sources and aggregates
···
137
132
}
138
133
139
134
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
140
-
follows, err := GetAllFollows(e, Limit)
135
+
follows, err := GetFollows(e, Limit)
141
136
if err != nil {
142
137
return nil, err
143
138
}
···
151
146
return nil, nil
152
147
}
153
148
154
-
profileMap := make(map[string]Profile)
155
149
profiles, err := GetProfiles(e, FilterIn("did", subjects))
156
150
if err != nil {
157
151
return nil, err
158
152
}
159
-
for _, p := range profiles {
160
-
profileMap[p.Did] = p
161
-
}
162
153
163
-
followStatMap := make(map[string]FollowStats)
164
-
for _, s := range subjects {
165
-
followers, following, err := GetFollowerFollowing(e, s)
166
-
if err != nil {
167
-
return nil, err
168
-
}
169
-
followStatMap[s] = FollowStats{
170
-
Followers: followers,
171
-
Following: following,
172
-
}
154
+
followStatMap, err := GetFollowerFollowingCounts(e, subjects)
155
+
if err != nil {
156
+
return nil, err
173
157
}
174
158
175
159
var events []TimelineEvent
176
160
for _, f := range follows {
177
-
profile, _ := profileMap[f.SubjectDid]
161
+
profile, _ := profiles[f.SubjectDid]
178
162
followStatMap, _ := followStatMap[f.SubjectDid]
179
163
180
164
events = append(events, TimelineEvent{
181
165
Follow: &f,
182
-
Profile: &profile,
166
+
Profile: profile,
183
167
FollowStats: &followStatMap,
184
168
EventAt: f.FollowedAt,
185
169
})
+342
-10
appview/ingester.go
+342
-10
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/spindleverify"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
19
+
"tangled.sh/tangled.sh/core/appview/serververify"
18
20
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
20
22
)
···
61
63
case tangled.ActorProfileNSID:
62
64
err = i.ingestProfile(e)
63
65
case tangled.SpindleMemberNSID:
64
-
err = i.ingestSpindleMember(e)
66
+
err = i.ingestSpindleMember(ctx, e)
65
67
case tangled.SpindleNSID:
66
-
err = i.ingestSpindle(e)
68
+
err = i.ingestSpindle(ctx, e)
69
+
case tangled.KnotMemberNSID:
70
+
err = i.ingestKnotMember(e)
71
+
case tangled.KnotNSID:
72
+
err = i.ingestKnot(e)
67
73
case tangled.StringNSID:
68
74
err = i.ingestString(e)
75
+
case tangled.RepoIssueNSID:
76
+
err = i.ingestIssue(ctx, e)
77
+
case tangled.RepoIssueCommentNSID:
78
+
err = i.ingestIssueComment(e)
69
79
}
70
80
l = i.Logger.With("nsid", e.Commit.Collection)
71
81
}
72
82
73
83
if err != nil {
74
-
l.Error("error ingesting record", "err", err)
84
+
l.Debug("error ingesting record", "err", err)
75
85
}
76
86
77
-
return err
87
+
return nil
78
88
}
79
89
}
80
90
···
336
346
return nil
337
347
}
338
348
339
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
349
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
340
350
did := e.Did
341
351
var err error
342
352
···
359
369
return fmt.Errorf("failed to enforce permissions: %w", err)
360
370
}
361
371
362
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
372
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
363
373
if err != nil {
364
374
return err
365
375
}
···
442
452
return nil
443
453
}
444
454
445
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
455
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
446
456
did := e.Did
447
457
var err error
448
458
···
475
485
return err
476
486
}
477
487
478
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
488
+
err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
479
489
if err != nil {
480
490
l.Error("failed to add spindle to db", "err", err, "instance", instance)
481
491
return err
482
492
}
483
493
484
-
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
494
+
_, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did)
485
495
if err != nil {
486
496
return fmt.Errorf("failed to mark verified: %w", err)
487
497
}
···
609
619
610
620
return nil
611
621
}
622
+
623
+
func (i *Ingester) ingestKnotMember(e *models.Event) error {
624
+
did := e.Did
625
+
var err error
626
+
627
+
l := i.Logger.With("handler", "ingestKnotMember")
628
+
l = l.With("nsid", e.Commit.Collection)
629
+
630
+
switch e.Commit.Operation {
631
+
case models.CommitOperationCreate:
632
+
raw := json.RawMessage(e.Commit.Record)
633
+
record := tangled.KnotMember{}
634
+
err = json.Unmarshal(raw, &record)
635
+
if err != nil {
636
+
l.Error("invalid record", "err", err)
637
+
return err
638
+
}
639
+
640
+
// only knot owner can invite to knots
641
+
ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain)
642
+
if err != nil || !ok {
643
+
return fmt.Errorf("failed to enforce permissions: %w", err)
644
+
}
645
+
646
+
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
647
+
if err != nil {
648
+
return err
649
+
}
650
+
651
+
if memberId.Handle.IsInvalidHandle() {
652
+
return err
653
+
}
654
+
655
+
err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String())
656
+
if err != nil {
657
+
return fmt.Errorf("failed to update ACLs: %w", err)
658
+
}
659
+
660
+
l.Info("added knot member")
661
+
case models.CommitOperationDelete:
662
+
// we don't store knot members in a table (like we do for spindle)
663
+
// and we can't remove this just yet. possibly fixed if we switch
664
+
// to either:
665
+
// 1. a knot_members table like with spindle and store the rkey
666
+
// 2. use the knot host as the rkey
667
+
//
668
+
// TODO: implement member deletion
669
+
l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey)
670
+
}
671
+
672
+
return nil
673
+
}
674
+
675
+
func (i *Ingester) ingestKnot(e *models.Event) error {
676
+
did := e.Did
677
+
var err error
678
+
679
+
l := i.Logger.With("handler", "ingestKnot")
680
+
l = l.With("nsid", e.Commit.Collection)
681
+
682
+
switch e.Commit.Operation {
683
+
case models.CommitOperationCreate:
684
+
raw := json.RawMessage(e.Commit.Record)
685
+
record := tangled.Knot{}
686
+
err = json.Unmarshal(raw, &record)
687
+
if err != nil {
688
+
l.Error("invalid record", "err", err)
689
+
return err
690
+
}
691
+
692
+
domain := e.Commit.RKey
693
+
694
+
ddb, ok := i.Db.Execer.(*db.DB)
695
+
if !ok {
696
+
return fmt.Errorf("failed to index profile record, invalid db cast")
697
+
}
698
+
699
+
err := db.AddKnot(ddb, domain, did)
700
+
if err != nil {
701
+
l.Error("failed to add knot to db", "err", err, "domain", domain)
702
+
return err
703
+
}
704
+
705
+
err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev)
706
+
if err != nil {
707
+
l.Error("failed to verify knot", "err", err, "domain", domain)
708
+
return err
709
+
}
710
+
711
+
err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did)
712
+
if err != nil {
713
+
return fmt.Errorf("failed to mark verified: %w", err)
714
+
}
715
+
716
+
return nil
717
+
718
+
case models.CommitOperationDelete:
719
+
domain := e.Commit.RKey
720
+
721
+
ddb, ok := i.Db.Execer.(*db.DB)
722
+
if !ok {
723
+
return fmt.Errorf("failed to index knot record, invalid db cast")
724
+
}
725
+
726
+
// get record from db first
727
+
registrations, err := db.GetRegistrations(
728
+
ddb,
729
+
db.FilterEq("domain", domain),
730
+
db.FilterEq("did", did),
731
+
)
732
+
if err != nil {
733
+
return fmt.Errorf("failed to get registration: %w", err)
734
+
}
735
+
if len(registrations) != 1 {
736
+
return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations))
737
+
}
738
+
registration := registrations[0]
739
+
740
+
tx, err := ddb.Begin()
741
+
if err != nil {
742
+
return err
743
+
}
744
+
defer func() {
745
+
tx.Rollback()
746
+
i.Enforcer.E.LoadPolicy()
747
+
}()
748
+
749
+
err = db.DeleteKnot(
750
+
tx,
751
+
db.FilterEq("did", did),
752
+
db.FilterEq("domain", domain),
753
+
)
754
+
if err != nil {
755
+
return err
756
+
}
757
+
758
+
if registration.Registered != nil {
759
+
err = i.Enforcer.RemoveKnot(domain)
760
+
if err != nil {
761
+
return err
762
+
}
763
+
}
764
+
765
+
err = tx.Commit()
766
+
if err != nil {
767
+
return err
768
+
}
769
+
770
+
err = i.Enforcer.E.SavePolicy()
771
+
if err != nil {
772
+
return err
773
+
}
774
+
}
775
+
776
+
return nil
777
+
}
778
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
779
+
did := e.Did
780
+
rkey := e.Commit.RKey
781
+
782
+
var err error
783
+
784
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
785
+
l.Info("ingesting record")
786
+
787
+
ddb, ok := i.Db.Execer.(*db.DB)
788
+
if !ok {
789
+
return fmt.Errorf("failed to index issue record, invalid db cast")
790
+
}
791
+
792
+
switch e.Commit.Operation {
793
+
case models.CommitOperationCreate:
794
+
raw := json.RawMessage(e.Commit.Record)
795
+
record := tangled.RepoIssue{}
796
+
err = json.Unmarshal(raw, &record)
797
+
if err != nil {
798
+
l.Error("invalid record", "err", err)
799
+
return err
800
+
}
801
+
802
+
issue := db.IssueFromRecord(did, rkey, record)
803
+
804
+
sanitizer := markup.NewSanitizer()
805
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
806
+
return fmt.Errorf("title is empty after HTML sanitization")
807
+
}
808
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
809
+
return fmt.Errorf("body is empty after HTML sanitization")
810
+
}
811
+
812
+
tx, err := ddb.BeginTx(ctx, nil)
813
+
if err != nil {
814
+
l.Error("failed to begin transaction", "err", err)
815
+
return err
816
+
}
817
+
818
+
err = db.NewIssue(tx, &issue)
819
+
if err != nil {
820
+
l.Error("failed to create issue", "err", err)
821
+
return err
822
+
}
823
+
824
+
return nil
825
+
826
+
case models.CommitOperationUpdate:
827
+
raw := json.RawMessage(e.Commit.Record)
828
+
record := tangled.RepoIssue{}
829
+
err = json.Unmarshal(raw, &record)
830
+
if err != nil {
831
+
l.Error("invalid record", "err", err)
832
+
return err
833
+
}
834
+
835
+
body := ""
836
+
if record.Body != nil {
837
+
body = *record.Body
838
+
}
839
+
840
+
sanitizer := markup.NewSanitizer()
841
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
842
+
return fmt.Errorf("title is empty after HTML sanitization")
843
+
}
844
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
845
+
return fmt.Errorf("body is empty after HTML sanitization")
846
+
}
847
+
848
+
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
849
+
if err != nil {
850
+
l.Error("failed to update issue", "err", err)
851
+
return err
852
+
}
853
+
854
+
return nil
855
+
856
+
case models.CommitOperationDelete:
857
+
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
858
+
l.Error("failed to delete", "err", err)
859
+
return fmt.Errorf("failed to delete issue record: %w", err)
860
+
}
861
+
862
+
return nil
863
+
}
864
+
865
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
866
+
}
867
+
868
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
869
+
did := e.Did
870
+
rkey := e.Commit.RKey
871
+
872
+
var err error
873
+
874
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
875
+
l.Info("ingesting record")
876
+
877
+
ddb, ok := i.Db.Execer.(*db.DB)
878
+
if !ok {
879
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
880
+
}
881
+
882
+
switch e.Commit.Operation {
883
+
case models.CommitOperationCreate:
884
+
raw := json.RawMessage(e.Commit.Record)
885
+
record := tangled.RepoIssueComment{}
886
+
err = json.Unmarshal(raw, &record)
887
+
if err != nil {
888
+
l.Error("invalid record", "err", err)
889
+
return err
890
+
}
891
+
892
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
893
+
if err != nil {
894
+
l.Error("failed to parse comment from record", "err", err)
895
+
return err
896
+
}
897
+
898
+
sanitizer := markup.NewSanitizer()
899
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
900
+
return fmt.Errorf("body is empty after HTML sanitization")
901
+
}
902
+
903
+
err = db.NewIssueComment(ddb, &comment)
904
+
if err != nil {
905
+
l.Error("failed to create issue comment", "err", err)
906
+
return err
907
+
}
908
+
909
+
return nil
910
+
911
+
case models.CommitOperationUpdate:
912
+
raw := json.RawMessage(e.Commit.Record)
913
+
record := tangled.RepoIssueComment{}
914
+
err = json.Unmarshal(raw, &record)
915
+
if err != nil {
916
+
l.Error("invalid record", "err", err)
917
+
return err
918
+
}
919
+
920
+
sanitizer := markup.NewSanitizer()
921
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
922
+
return fmt.Errorf("body is empty after HTML sanitization")
923
+
}
924
+
925
+
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
926
+
if err != nil {
927
+
l.Error("failed to update issue comment", "err", err)
928
+
return err
929
+
}
930
+
931
+
return nil
932
+
933
+
case models.CommitOperationDelete:
934
+
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
935
+
l.Error("failed to delete", "err", err)
936
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
937
+
}
938
+
939
+
return nil
940
+
}
941
+
942
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
943
+
}
+40
-95
appview/issues/issues.go
+40
-95
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
-
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
16
"github.com/go-chi/chi/v5"
17
17
···
21
21
"tangled.sh/tangled.sh/core/appview/notify"
22
22
"tangled.sh/tangled.sh/core/appview/oauth"
23
23
"tangled.sh/tangled.sh/core/appview/pages"
24
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
25
"tangled.sh/tangled.sh/core/appview/pagination"
25
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
27
"tangled.sh/tangled.sh/core/idresolver"
···
73
74
return
74
75
}
75
76
76
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
77
+
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
77
78
if err != nil {
78
79
log.Println("failed to get issue and comments", err)
79
80
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
80
81
return
81
82
}
82
83
83
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt))
84
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
84
85
if err != nil {
85
86
log.Println("failed to get issue reactions")
86
87
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
···
88
89
89
90
userReactions := map[db.ReactionKind]bool{}
90
91
if user != nil {
91
-
userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt))
92
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
92
93
}
93
94
94
95
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
···
96
97
log.Println("failed to resolve issue owner", err)
97
98
}
98
99
99
-
identsToResolve := make([]string, len(comments))
100
-
for i, comment := range comments {
101
-
identsToResolve[i] = comment.OwnerDid
102
-
}
103
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
104
-
didHandleMap := make(map[string]string)
105
-
for _, identity := range resolvedIds {
106
-
if !identity.Handle.IsInvalidHandle() {
107
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
108
-
} else {
109
-
didHandleMap[identity.DID.String()] = identity.DID.String()
110
-
}
111
-
}
112
-
113
100
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
114
101
LoggedInUser: user,
115
102
RepoInfo: f.RepoInfo(user),
116
-
Issue: *issue,
103
+
Issue: issue,
117
104
Comments: comments,
118
105
119
106
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
120
-
DidHandleMap: didHandleMap,
121
107
122
108
OrderedReactionKinds: db.OrderedReactionKinds,
123
109
Reactions: reactionCountMap,
···
142
128
return
143
129
}
144
130
145
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
131
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
146
132
if err != nil {
147
133
log.Println("failed to get issue", err)
148
134
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
174
160
Rkey: tid.TID(),
175
161
Record: &lexutil.LexiconTypeDecoder{
176
162
Val: &tangled.RepoIssueState{
177
-
Issue: issue.IssueAt,
163
+
Issue: issue.AtUri().String(),
178
164
State: closed,
179
165
},
180
166
},
···
186
172
return
187
173
}
188
174
189
-
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
175
+
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
190
176
if err != nil {
191
177
log.Println("failed to close issue", err)
192
178
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
218
204
return
219
205
}
220
206
221
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
207
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
222
208
if err != nil {
223
209
log.Println("failed to get issue", err)
224
210
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
235
221
isIssueOwner := user.Did == issue.OwnerDid
236
222
237
223
if isCollaborator || isIssueOwner {
238
-
err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt)
224
+
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
239
225
if err != nil {
240
226
log.Println("failed to reopen issue", err)
241
227
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
···
279
265
280
266
err := db.NewIssueComment(rp.db, &db.Comment{
281
267
OwnerDid: user.Did,
282
-
RepoAt: f.RepoAt,
268
+
RepoAt: f.RepoAt(),
283
269
Issue: issueIdInt,
284
270
CommentId: commentId,
285
271
Body: body,
···
292
278
}
293
279
294
280
createdAt := time.Now().Format(time.RFC3339)
295
-
commentIdInt64 := int64(commentId)
296
281
ownerDid := user.Did
297
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt)
282
+
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
298
283
if err != nil {
299
284
log.Println("failed to get issue at", err)
300
285
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
301
286
return
302
287
}
303
288
304
-
atUri := f.RepoAt.String()
289
+
atUri := f.RepoAt().String()
305
290
client, err := rp.oauth.AuthorizedClient(r)
306
291
if err != nil {
307
292
log.Println("failed to get authorized client", err)
···
316
301
Val: &tangled.RepoIssueComment{
317
302
Repo: &atUri,
318
303
Issue: issueAt,
319
-
CommentId: &commentIdInt64,
320
304
Owner: &ownerDid,
321
305
Body: body,
322
306
CreatedAt: createdAt,
···
358
342
return
359
343
}
360
344
361
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
345
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
362
346
if err != nil {
363
347
log.Println("failed to get issue", err)
364
348
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
365
349
return
366
350
}
367
351
368
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
352
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
369
353
if err != nil {
370
354
http.Error(w, "bad comment id", http.StatusBadRequest)
371
355
return
372
356
}
373
357
374
-
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
375
-
if err != nil {
376
-
log.Println("failed to resolve did")
377
-
return
378
-
}
379
-
380
-
didHandleMap := make(map[string]string)
381
-
if !identity.Handle.IsInvalidHandle() {
382
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
383
-
} else {
384
-
didHandleMap[identity.DID.String()] = identity.DID.String()
385
-
}
386
-
387
358
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
388
359
LoggedInUser: user,
389
360
RepoInfo: f.RepoInfo(user),
390
-
DidHandleMap: didHandleMap,
391
361
Issue: issue,
392
362
Comment: comment,
393
363
})
···
417
387
return
418
388
}
419
389
420
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
390
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
421
391
if err != nil {
422
392
log.Println("failed to get issue", err)
423
393
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
424
394
return
425
395
}
426
396
427
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
397
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
428
398
if err != nil {
429
399
http.Error(w, "bad comment id", http.StatusBadRequest)
430
400
return
···
479
449
repoAt := record["repo"].(string)
480
450
issueAt := record["issue"].(string)
481
451
createdAt := record["createdAt"].(string)
482
-
commentIdInt64 := int64(commentIdInt)
483
452
484
453
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
485
454
Collection: tangled.RepoIssueCommentNSID,
···
490
459
Val: &tangled.RepoIssueComment{
491
460
Repo: &repoAt,
492
461
Issue: issueAt,
493
-
CommentId: &commentIdInt64,
494
462
Owner: &comment.OwnerDid,
495
463
Body: newBody,
496
464
CreatedAt: createdAt,
···
503
471
}
504
472
505
473
// optimistic update for htmx
506
-
didHandleMap := map[string]string{
507
-
user.Did: user.Handle,
508
-
}
509
474
comment.Body = newBody
510
475
comment.Edited = &edited
511
476
···
513
478
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
514
479
LoggedInUser: user,
515
480
RepoInfo: f.RepoInfo(user),
516
-
DidHandleMap: didHandleMap,
517
481
Issue: issue,
518
482
Comment: comment,
519
483
})
···
539
503
return
540
504
}
541
505
542
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
506
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
543
507
if err != nil {
544
508
log.Println("failed to get issue", err)
545
509
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
···
554
518
return
555
519
}
556
520
557
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
521
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
558
522
if err != nil {
559
523
http.Error(w, "bad comment id", http.StatusBadRequest)
560
524
return
···
572
536
573
537
// optimistic deletion
574
538
deleted := time.Now()
575
-
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
539
+
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
576
540
if err != nil {
577
541
log.Println("failed to delete comment")
578
542
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
598
562
}
599
563
600
564
// optimistic update for htmx
601
-
didHandleMap := map[string]string{
602
-
user.Did: user.Handle,
603
-
}
604
565
comment.Body = ""
605
566
comment.Deleted = &deleted
606
567
···
608
569
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
609
570
LoggedInUser: user,
610
571
RepoInfo: f.RepoInfo(user),
611
-
DidHandleMap: didHandleMap,
612
572
Issue: issue,
613
573
Comment: comment,
614
574
})
615
-
return
616
575
}
617
576
618
577
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
···
641
600
return
642
601
}
643
602
644
-
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
603
+
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
645
604
if err != nil {
646
605
log.Println("failed to get issues", err)
647
606
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
648
607
return
649
608
}
650
609
651
-
identsToResolve := make([]string, len(issues))
652
-
for i, issue := range issues {
653
-
identsToResolve[i] = issue.OwnerDid
654
-
}
655
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
656
-
didHandleMap := make(map[string]string)
657
-
for _, identity := range resolvedIds {
658
-
if !identity.Handle.IsInvalidHandle() {
659
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
660
-
} else {
661
-
didHandleMap[identity.DID.String()] = identity.DID.String()
662
-
}
663
-
}
664
-
665
610
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
666
611
LoggedInUser: rp.oauth.GetUser(r),
667
612
RepoInfo: f.RepoInfo(user),
668
613
Issues: issues,
669
-
DidHandleMap: didHandleMap,
670
614
FilteringByOpen: isOpen,
671
615
Page: page,
672
616
})
673
-
return
674
617
}
675
618
676
619
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
···
697
640
return
698
641
}
699
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
+
700
653
tx, err := rp.db.BeginTx(r.Context(), nil)
701
654
if err != nil {
702
655
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
···
704
657
}
705
658
706
659
issue := &db.Issue{
707
-
RepoAt: f.RepoAt,
660
+
RepoAt: f.RepoAt(),
661
+
Rkey: tid.TID(),
708
662
Title: title,
709
663
Body: body,
710
664
OwnerDid: user.Did,
···
722
676
rp.pages.Notice(w, "issues", "Failed to create issue.")
723
677
return
724
678
}
725
-
atUri := f.RepoAt.String()
726
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
679
+
atUri := f.RepoAt().String()
680
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
727
681
Collection: tangled.RepoIssueNSID,
728
682
Repo: user.Did,
729
-
Rkey: tid.TID(),
683
+
Rkey: issue.Rkey,
730
684
Record: &lexutil.LexiconTypeDecoder{
731
685
Val: &tangled.RepoIssue{
732
-
Repo: atUri,
733
-
Title: title,
734
-
Body: &body,
735
-
Owner: user.Did,
736
-
IssueId: int64(issue.IssueId),
686
+
Repo: atUri,
687
+
Title: title,
688
+
Body: &body,
737
689
},
738
690
},
739
691
})
740
692
if err != nil {
741
693
log.Println("failed to create issue", err)
742
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
743
-
return
744
-
}
745
-
746
-
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
747
-
if err != nil {
748
-
log.Println("failed to set issue at", err)
749
694
rp.pages.Notice(w, "issues", "Failed to create issue.")
750
695
return
751
696
}
+443
-232
appview/knots/knots.go
+443
-232
appview/knots/knots.go
···
1
1
package knots
2
2
3
3
import (
4
-
"context"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
4
+
"errors"
8
5
"fmt"
6
+
"log"
9
7
"log/slog"
10
8
"net/http"
11
-
"strings"
9
+
"slices"
12
10
"time"
13
11
14
12
"github.com/go-chi/chi/v5"
···
18
16
"tangled.sh/tangled.sh/core/appview/middleware"
19
17
"tangled.sh/tangled.sh/core/appview/oauth"
20
18
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/serververify"
21
20
"tangled.sh/tangled.sh/core/eventconsumer"
22
21
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/knotclient"
24
22
"tangled.sh/tangled.sh/core/rbac"
25
23
"tangled.sh/tangled.sh/core/tid"
26
24
···
39
37
Knotstream *eventconsumer.Consumer
40
38
}
41
39
42
-
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
40
+
func (k *Knots) Router() http.Handler {
43
41
r := chi.NewRouter()
44
42
45
-
r.Use(middleware.AuthMiddleware(k.OAuth))
43
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
44
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
45
+
46
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
47
+
r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
46
48
47
-
r.Get("/", k.index)
48
-
r.Post("/key", k.generateKey)
49
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
50
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
51
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
49
52
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
-
})
53
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
60
54
61
55
return r
62
56
}
63
57
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
-
58
+
func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
68
59
user := k.OAuth.GetUser(r)
69
-
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
60
+
registrations, err := db.GetRegistrations(
61
+
k.Db,
62
+
db.FilterEq("did", user.Did),
63
+
)
70
64
if err != nil {
71
-
l.Error("failed to get registrations by did", "err", err)
65
+
k.Logger.Error("failed to fetch knot registrations", "err", err)
66
+
w.WriteHeader(http.StatusInternalServerError)
67
+
return
72
68
}
73
69
74
70
k.Pages.Knots(w, pages.KnotsParams{
···
77
73
})
78
74
}
79
75
80
-
// requires auth
81
-
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
82
-
l := k.Logger.With("handler", "generateKey")
76
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
77
+
l := k.Logger.With("handler", "dashboard")
83
78
84
79
user := k.OAuth.GetUser(r)
85
-
did := user.Did
86
-
l = l.With("did", did)
80
+
l = l.With("user", user.Did)
87
81
88
-
// check if domain is valid url, and strip extra bits down to just host
89
-
domain := r.FormValue("domain")
82
+
domain := chi.URLParam(r, "domain")
90
83
if domain == "" {
91
-
l.Error("empty domain")
92
-
http.Error(w, "Invalid form", http.StatusBadRequest)
93
84
return
94
85
}
95
86
l = l.With("domain", domain)
96
87
97
-
noticeId := "registration-error"
98
-
fail := func() {
99
-
k.Pages.Notice(w, noticeId, "Failed to generate registration key.")
88
+
registrations, err := db.GetRegistrations(
89
+
k.Db,
90
+
db.FilterEq("did", user.Did),
91
+
db.FilterEq("domain", domain),
92
+
)
93
+
if err != nil {
94
+
l.Error("failed to get registrations", "err", err)
95
+
http.Error(w, "Not found", http.StatusNotFound)
96
+
return
100
97
}
98
+
if len(registrations) != 1 {
99
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
100
+
return
101
+
}
102
+
registration := registrations[0]
101
103
102
-
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
104
+
members, err := k.Enforcer.GetUserByRole("server:member", domain)
103
105
if err != nil {
104
-
l.Error("failed to generate registration key", "err", err)
105
-
fail()
106
+
l.Error("failed to get knot members", "err", err)
107
+
http.Error(w, "Not found", http.StatusInternalServerError)
106
108
return
107
109
}
110
+
slices.Sort(members)
108
111
109
-
allRegs, err := db.RegistrationsByDid(k.Db, did)
112
+
repos, err := db.GetRepos(
113
+
k.Db,
114
+
0,
115
+
db.FilterEq("knot", domain),
116
+
)
110
117
if err != nil {
111
-
l.Error("failed to generate registration key", "err", err)
112
-
fail()
118
+
l.Error("failed to get knot repos", "err", err)
119
+
http.Error(w, "Not found", http.StatusInternalServerError)
113
120
return
114
121
}
115
122
116
-
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
117
-
Registrations: allRegs,
118
-
})
119
-
k.Pages.KnotSecret(w, pages.KnotSecretParams{
120
-
Secret: key,
123
+
// organize repos by did
124
+
repoMap := make(map[string][]db.Repo)
125
+
for _, r := range repos {
126
+
repoMap[r.Did] = append(repoMap[r.Did], r)
127
+
}
128
+
129
+
k.Pages.Knot(w, pages.KnotParams{
130
+
LoggedInUser: user,
131
+
Registration: ®istration,
132
+
Members: members,
133
+
Repos: repoMap,
134
+
IsOwner: true,
121
135
})
122
136
}
123
137
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")
138
+
func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
127
139
user := k.OAuth.GetUser(r)
140
+
l := k.Logger.With("handler", "register")
128
141
129
-
noticeId := "operation-error"
130
-
defaultErr := "Failed to initialize knot. Try again later."
142
+
noticeId := "register-error"
143
+
defaultErr := "Failed to register knot. Try again later."
131
144
fail := func() {
132
145
k.Pages.Notice(w, noticeId, defaultErr)
133
146
}
134
147
135
-
domain := chi.URLParam(r, "domain")
148
+
domain := r.FormValue("domain")
136
149
if domain == "" {
137
-
http.Error(w, "malformed url", http.StatusBadRequest)
150
+
k.Pages.Notice(w, noticeId, "Incomplete form.")
138
151
return
139
152
}
140
153
l = l.With("domain", domain)
154
+
l = l.With("user", user.Did)
141
155
142
-
l.Info("checking domain")
156
+
tx, err := k.Db.Begin()
157
+
if err != nil {
158
+
l.Error("failed to start transaction", "err", err)
159
+
fail()
160
+
return
161
+
}
162
+
defer func() {
163
+
tx.Rollback()
164
+
k.Enforcer.E.LoadPolicy()
165
+
}()
143
166
144
-
registration, err := db.RegistrationByDomain(k.Db, domain)
167
+
err = db.AddKnot(tx, domain, user.Did)
145
168
if err != nil {
146
-
l.Error("failed to get registration for domain", "err", err)
169
+
l.Error("failed to insert", "err", err)
147
170
fail()
148
171
return
149
172
}
150
-
if registration.ByDid != user.Did {
151
-
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
152
-
w.WriteHeader(http.StatusUnauthorized)
173
+
174
+
err = k.Enforcer.AddKnot(domain)
175
+
if err != nil {
176
+
l.Error("failed to create knot", "err", err)
177
+
fail()
153
178
return
154
179
}
155
180
156
-
secret, err := db.GetRegistrationKey(k.Db, domain)
181
+
// create record on pds
182
+
client, err := k.OAuth.AuthorizedClient(r)
157
183
if err != nil {
158
-
l.Error("failed to get registration key for domain", "err", err)
184
+
l.Error("failed to authorize client", "err", err)
159
185
fail()
160
186
return
161
187
}
162
188
163
-
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
189
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
190
+
var exCid *string
191
+
if ex != nil {
192
+
exCid = ex.Cid
193
+
}
194
+
195
+
// re-announce by registering under same rkey
196
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
197
+
Collection: tangled.KnotNSID,
198
+
Repo: user.Did,
199
+
Rkey: domain,
200
+
Record: &lexutil.LexiconTypeDecoder{
201
+
Val: &tangled.Knot{
202
+
CreatedAt: time.Now().Format(time.RFC3339),
203
+
},
204
+
},
205
+
SwapRecord: exCid,
206
+
})
207
+
164
208
if err != nil {
165
-
l.Error("failed to create knotclient", "err", err)
209
+
l.Error("failed to put record", "err", err)
166
210
fail()
167
211
return
168
212
}
169
213
170
-
resp, err := client.Init(user.Did)
214
+
err = tx.Commit()
171
215
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)
216
+
l.Error("failed to commit transaction", "err", err)
217
+
fail()
174
218
return
175
219
}
176
220
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)
221
+
err = k.Enforcer.E.SavePolicy()
222
+
if err != nil {
223
+
l.Error("failed to update ACL", "err", err)
224
+
k.Pages.HxRefresh(w)
180
225
return
181
226
}
182
227
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)
228
+
// begin verification
229
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
230
+
if err != nil {
231
+
l.Error("verification failed", "err", err)
232
+
k.Pages.HxRefresh(w)
186
233
return
187
234
}
188
235
189
-
// verify response mac
190
-
signature := resp.Header.Get("X-Signature")
191
-
signatureBytes, err := hex.DecodeString(signature)
236
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
192
237
if err != nil {
238
+
l.Error("failed to mark verified", "err", err)
239
+
k.Pages.HxRefresh(w)
193
240
return
194
241
}
195
242
196
-
expectedMac := hmac.New(sha256.New, []byte(secret))
197
-
expectedMac.Write([]byte("ok"))
243
+
// add this knot to knotstream
244
+
go k.Knotstream.AddSource(
245
+
r.Context(),
246
+
eventconsumer.NewKnotSource(domain),
247
+
)
248
+
249
+
// ok
250
+
k.Pages.HxRefresh(w)
251
+
}
252
+
253
+
func (k *Knots) delete(w http.ResponseWriter, r *http.Request) {
254
+
user := k.OAuth.GetUser(r)
255
+
l := k.Logger.With("handler", "delete")
256
+
257
+
noticeId := "operation-error"
258
+
defaultErr := "Failed to delete knot. Try again later."
259
+
fail := func() {
260
+
k.Pages.Notice(w, noticeId, defaultErr)
261
+
}
198
262
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)
263
+
domain := chi.URLParam(r, "domain")
264
+
if domain == "" {
265
+
l.Error("empty domain")
266
+
fail()
202
267
return
203
268
}
204
269
205
-
tx, err := k.Db.BeginTx(r.Context(), nil)
270
+
// get record from db first
271
+
registrations, err := db.GetRegistrations(
272
+
k.Db,
273
+
db.FilterEq("did", user.Did),
274
+
db.FilterEq("domain", domain),
275
+
)
206
276
if err != nil {
207
-
l.Error("failed to start tx", "err", err)
277
+
l.Error("failed to get registration", "err", err)
278
+
fail()
279
+
return
280
+
}
281
+
if len(registrations) != 1 {
282
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
283
+
fail()
284
+
return
285
+
}
286
+
registration := registrations[0]
287
+
288
+
tx, err := k.Db.Begin()
289
+
if err != nil {
290
+
l.Error("failed to start txn", "err", err)
208
291
fail()
209
292
return
210
293
}
211
294
defer func() {
212
295
tx.Rollback()
213
-
err = k.Enforcer.E.LoadPolicy()
214
-
if err != nil {
215
-
l.Error("rollback failed", "err", err)
216
-
}
296
+
k.Enforcer.E.LoadPolicy()
217
297
}()
218
298
219
-
// mark as registered
220
-
err = db.Register(tx, domain)
299
+
err = db.DeleteKnot(
300
+
tx,
301
+
db.FilterEq("did", user.Did),
302
+
db.FilterEq("domain", domain),
303
+
)
221
304
if err != nil {
222
-
l.Error("failed to register domain", "err", err)
305
+
l.Error("failed to delete registration", "err", err)
223
306
fail()
224
307
return
225
308
}
226
309
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
310
+
// delete from enforcer if it was registered
311
+
if registration.Registered != nil {
312
+
err = k.Enforcer.RemoveKnot(domain)
313
+
if err != nil {
314
+
l.Error("failed to update ACL", "err", err)
315
+
fail()
316
+
return
317
+
}
233
318
}
234
319
235
-
// add basic acls for this domain
236
-
err = k.Enforcer.AddKnot(domain)
320
+
client, err := k.OAuth.AuthorizedClient(r)
237
321
if err != nil {
238
-
l.Error("failed to add knot to enforcer", "err", err)
322
+
l.Error("failed to authorize client", "err", err)
239
323
fail()
240
324
return
241
325
}
242
326
243
-
// add this did as owner of this domain
244
-
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
327
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
328
+
Collection: tangled.KnotNSID,
329
+
Repo: user.Did,
330
+
Rkey: domain,
331
+
})
245
332
if err != nil {
246
-
l.Error("failed to add knot owner to enforcer", "err", err)
247
-
fail()
248
-
return
333
+
// non-fatal
334
+
l.Error("failed to delete record", "err", err)
249
335
}
250
336
251
337
err = tx.Commit()
252
338
if err != nil {
253
-
l.Error("failed to commit changes", "err", err)
339
+
l.Error("failed to delete knot", "err", err)
254
340
fail()
255
341
return
256
342
}
257
343
258
344
err = k.Enforcer.E.SavePolicy()
259
345
if err != nil {
260
-
l.Error("failed to update ACLs", "err", err)
261
-
fail()
346
+
l.Error("failed to update ACL", "err", err)
347
+
k.Pages.HxRefresh(w)
262
348
return
263
349
}
264
350
265
-
// add this knot to knotstream
266
-
go k.Knotstream.AddSource(
267
-
context.Background(),
268
-
eventconsumer.NewKnotSource(domain),
269
-
)
351
+
shouldRedirect := r.Header.Get("shouldRedirect")
352
+
if shouldRedirect == "true" {
353
+
k.Pages.HxRedirect(w, "/knots")
354
+
return
355
+
}
270
356
271
-
k.Pages.KnotListing(w, pages.KnotListingParams{
272
-
Registration: *reg,
273
-
})
357
+
w.Write([]byte{})
274
358
}
275
359
276
-
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
277
-
l := k.Logger.With("handler", "dashboard")
360
+
func (k *Knots) retry(w http.ResponseWriter, r *http.Request) {
361
+
user := k.OAuth.GetUser(r)
362
+
l := k.Logger.With("handler", "retry")
363
+
364
+
noticeId := "operation-error"
365
+
defaultErr := "Failed to verify knot. Try again later."
278
366
fail := func() {
279
-
w.WriteHeader(http.StatusInternalServerError)
367
+
k.Pages.Notice(w, noticeId, defaultErr)
280
368
}
281
369
282
370
domain := chi.URLParam(r, "domain")
283
371
if domain == "" {
284
-
http.Error(w, "malformed url", http.StatusBadRequest)
372
+
l.Error("empty domain")
373
+
fail()
285
374
return
286
375
}
287
376
l = l.With("domain", domain)
377
+
l = l.With("user", user.Did)
288
378
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)
379
+
// get record from db first
380
+
registrations, err := db.GetRegistrations(
381
+
k.Db,
382
+
db.FilterEq("did", user.Did),
383
+
db.FilterEq("domain", domain),
384
+
)
294
385
if err != nil {
295
-
l.Error("failed to query enforcer", "err", err)
386
+
l.Error("failed to get registration", "err", err)
296
387
fail()
388
+
return
297
389
}
298
-
if !ok {
299
-
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
390
+
if len(registrations) != 1 {
391
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
392
+
fail()
300
393
return
301
394
}
395
+
registration := registrations[0]
302
396
303
-
reg, err := db.RegistrationByDomain(k.Db, domain)
397
+
// begin verification
398
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
304
399
if err != nil {
305
-
l.Error("failed to get registration by domain", "err", err)
400
+
l.Error("verification failed", "err", err)
401
+
402
+
if errors.Is(err, serververify.FetchError) {
403
+
k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
404
+
return
405
+
}
406
+
407
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
408
+
k.Pages.Notice(w, noticeId, e.Error())
409
+
return
410
+
}
411
+
306
412
fail()
307
413
return
308
414
}
309
415
310
-
var members []string
311
-
if reg.Registered != nil {
312
-
members, err = k.Enforcer.GetUserByRole("server:member", domain)
416
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
417
+
if err != nil {
418
+
l.Error("failed to mark verified", "err", err)
419
+
k.Pages.Notice(w, noticeId, err.Error())
420
+
return
421
+
}
422
+
423
+
// if this knot was previously read-only, then emit a record too
424
+
//
425
+
// this is part of migrating from the old knot system to the new one
426
+
if registration.ReadOnly {
427
+
// re-announce by registering under same rkey
428
+
client, err := k.OAuth.AuthorizedClient(r)
313
429
if err != nil {
314
-
l.Error("failed to get members list", "err", err)
430
+
l.Error("failed to authorize client", "err", err)
315
431
fail()
316
432
return
317
433
}
434
+
435
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
436
+
var exCid *string
437
+
if ex != nil {
438
+
exCid = ex.Cid
439
+
}
440
+
441
+
// ignore the error here
442
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
443
+
Collection: tangled.KnotNSID,
444
+
Repo: user.Did,
445
+
Rkey: domain,
446
+
Record: &lexutil.LexiconTypeDecoder{
447
+
Val: &tangled.Knot{
448
+
CreatedAt: time.Now().Format(time.RFC3339),
449
+
},
450
+
},
451
+
SwapRecord: exCid,
452
+
})
453
+
if err != nil {
454
+
l.Error("non-fatal: failed to reannouce knot", "err", err)
455
+
}
318
456
}
319
457
320
-
repos, err := db.GetRepos(
458
+
// add this knot to knotstream
459
+
go k.Knotstream.AddSource(
460
+
r.Context(),
461
+
eventconsumer.NewKnotSource(domain),
462
+
)
463
+
464
+
shouldRefresh := r.Header.Get("shouldRefresh")
465
+
if shouldRefresh == "true" {
466
+
k.Pages.HxRefresh(w)
467
+
return
468
+
}
469
+
470
+
// Get updated registration to show
471
+
registrations, err = db.GetRegistrations(
321
472
k.Db,
322
-
0,
323
-
db.FilterEq("knot", domain),
324
-
db.FilterIn("did", members),
473
+
db.FilterEq("did", user.Did),
474
+
db.FilterEq("domain", domain),
325
475
)
326
476
if err != nil {
327
-
l.Error("failed to get repos list", "err", err)
477
+
l.Error("failed to get registration", "err", err)
328
478
fail()
329
479
return
330
480
}
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)
481
+
if len(registrations) != 1 {
482
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
483
+
fail()
484
+
return
335
485
}
486
+
updatedRegistration := registrations[0]
336
487
337
-
var didsToResolve []string
338
-
for _, m := range members {
339
-
didsToResolve = append(didsToResolve, m)
340
-
}
341
-
didsToResolve = append(didsToResolve, reg.ByDid)
342
-
resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve)
343
-
didHandleMap := make(map[string]string)
344
-
for _, identity := range resolvedIds {
345
-
if !identity.Handle.IsInvalidHandle() {
346
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
347
-
} else {
348
-
didHandleMap[identity.DID.String()] = identity.DID.String()
349
-
}
350
-
}
488
+
log.Println(updatedRegistration)
351
489
352
-
k.Pages.Knot(w, pages.KnotParams{
353
-
LoggedInUser: user,
354
-
DidHandleMap: didHandleMap,
355
-
Registration: reg,
356
-
Members: members,
357
-
Repos: repoByMember,
358
-
IsOwner: true,
490
+
w.Header().Set("HX-Reswap", "outerHTML")
491
+
k.Pages.KnotListing(w, pages.KnotListingParams{
492
+
Registration: &updatedRegistration,
359
493
})
360
494
}
361
495
362
-
// list members of domain, requires auth and requires owner status
363
-
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
364
-
l := k.Logger.With("handler", "members")
496
+
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
497
+
user := k.OAuth.GetUser(r)
498
+
l := k.Logger.With("handler", "addMember")
365
499
366
500
domain := chi.URLParam(r, "domain")
367
501
if domain == "" {
368
-
http.Error(w, "malformed url", http.StatusBadRequest)
502
+
l.Error("empty domain")
503
+
http.Error(w, "Not found", http.StatusNotFound)
369
504
return
370
505
}
371
506
l = l.With("domain", domain)
507
+
l = l.With("user", user.Did)
372
508
373
-
// list all members for this domain
374
-
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
509
+
registrations, err := db.GetRegistrations(
510
+
k.Db,
511
+
db.FilterEq("did", user.Did),
512
+
db.FilterEq("domain", domain),
513
+
db.FilterIsNot("registered", "null"),
514
+
)
375
515
if err != nil {
376
-
w.Write([]byte("failed to fetch member list"))
377
-
return
378
-
}
379
-
380
-
w.Write([]byte(strings.Join(memberDids, "\n")))
381
-
}
382
-
383
-
// add member to domain, requires auth and requires invite access
384
-
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
385
-
l := k.Logger.With("handler", "members")
386
-
387
-
domain := chi.URLParam(r, "domain")
388
-
if domain == "" {
389
-
http.Error(w, "malformed url", http.StatusBadRequest)
516
+
l.Error("failed to get registration", "err", err)
390
517
return
391
518
}
392
-
l = l.With("domain", domain)
393
-
394
-
reg, err := db.RegistrationByDomain(k.Db, domain)
395
-
if err != nil {
396
-
l.Error("failed to get registration by domain", "err", err)
397
-
http.Error(w, "malformed url", http.StatusBadRequest)
519
+
if len(registrations) != 1 {
520
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
398
521
return
399
522
}
523
+
registration := registrations[0]
400
524
401
-
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
402
-
l = l.With("notice-id", noticeId)
525
+
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
403
526
defaultErr := "Failed to add member. Try again later."
404
527
fail := func() {
405
528
k.Pages.Notice(w, noticeId, defaultErr)
406
529
}
407
530
408
-
subjectIdentifier := r.FormValue("subject")
409
-
if subjectIdentifier == "" {
410
-
http.Error(w, "malformed form", http.StatusBadRequest)
531
+
member := r.FormValue("member")
532
+
if member == "" {
533
+
l.Error("empty member")
534
+
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
411
535
return
412
536
}
413
-
l = l.With("subjectIdentifier", subjectIdentifier)
537
+
l = l.With("member", member)
414
538
415
-
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
539
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
416
540
if err != nil {
417
-
l.Error("failed to resolve identity", "err", err)
541
+
l.Error("failed to resolve member identity to handle", "err", err)
418
542
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
419
543
return
420
544
}
421
-
l = l.With("subjectDid", subjectIdentity.DID)
422
-
423
-
l.Info("adding member to knot")
545
+
if memberId.Handle.IsInvalidHandle() {
546
+
l.Error("failed to resolve member identity to handle")
547
+
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
548
+
return
549
+
}
424
550
425
-
// announce this relation into the firehose, store into owners' pds
551
+
// write to pds
426
552
client, err := k.OAuth.AuthorizedClient(r)
427
553
if err != nil {
428
-
l.Error("failed to create client", "err", err)
554
+
l.Error("failed to authorize client", "err", err)
429
555
fail()
430
556
return
431
557
}
432
558
433
-
currentUser := k.OAuth.GetUser(r)
434
-
createdAt := time.Now().Format(time.RFC3339)
435
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
559
+
rkey := tid.TID()
560
+
561
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
436
562
Collection: tangled.KnotMemberNSID,
437
-
Repo: currentUser.Did,
438
-
Rkey: tid.TID(),
563
+
Repo: user.Did,
564
+
Rkey: rkey,
439
565
Record: &lexutil.LexiconTypeDecoder{
440
566
Val: &tangled.KnotMember{
441
-
Subject: subjectIdentity.DID.String(),
567
+
CreatedAt: time.Now().Format(time.RFC3339),
442
568
Domain: domain,
443
-
CreatedAt: createdAt,
444
-
}},
569
+
Subject: memberId.DID.String(),
570
+
},
571
+
},
445
572
})
446
-
// invalid record
447
573
if err != nil {
448
-
l.Error("failed to write to PDS", "err", err)
449
-
fail()
574
+
l.Error("failed to add record to PDS", "err", err)
575
+
k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
450
576
return
451
577
}
452
-
l = l.With("at-uri", resp.Uri)
453
-
l.Info("wrote record to PDS")
454
578
455
-
secret, err := db.GetRegistrationKey(k.Db, domain)
579
+
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
456
580
if err != nil {
457
-
l.Error("failed to get registration key", "err", err)
581
+
l.Error("failed to add member to ACLs", "err", err)
458
582
fail()
459
583
return
460
584
}
461
585
462
-
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
586
+
err = k.Enforcer.E.SavePolicy()
463
587
if err != nil {
464
-
l.Error("failed to create client", "err", err)
588
+
l.Error("failed to save ACL policy", "err", err)
465
589
fail()
466
590
return
467
591
}
468
592
469
-
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
593
+
// success
594
+
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
595
+
}
596
+
597
+
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
598
+
user := k.OAuth.GetUser(r)
599
+
l := k.Logger.With("handler", "removeMember")
600
+
601
+
noticeId := "operation-error"
602
+
defaultErr := "Failed to remove member. Try again later."
603
+
fail := func() {
604
+
k.Pages.Notice(w, noticeId, defaultErr)
605
+
}
606
+
607
+
domain := chi.URLParam(r, "domain")
608
+
if domain == "" {
609
+
l.Error("empty domain")
610
+
fail()
611
+
return
612
+
}
613
+
l = l.With("domain", domain)
614
+
l = l.With("user", user.Did)
615
+
616
+
registrations, err := db.GetRegistrations(
617
+
k.Db,
618
+
db.FilterEq("did", user.Did),
619
+
db.FilterEq("domain", domain),
620
+
db.FilterIsNot("registered", "null"),
621
+
)
470
622
if err != nil {
471
-
l.Error("failed to reach knotserver", "err", err)
472
-
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
623
+
l.Error("failed to get registration", "err", err)
624
+
return
625
+
}
626
+
if len(registrations) != 1 {
627
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
473
628
return
474
629
}
475
630
476
-
if ksResp.StatusCode != http.StatusNoContent {
477
-
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
478
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
631
+
member := r.FormValue("member")
632
+
if member == "" {
633
+
l.Error("empty member")
634
+
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
479
635
return
480
636
}
637
+
l = l.With("member", member)
481
638
482
-
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
639
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
483
640
if err != nil {
484
-
l.Error("failed to add member to enforcer", "err", err)
641
+
l.Error("failed to resolve member identity to handle", "err", err)
642
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
643
+
return
644
+
}
645
+
if memberId.Handle.IsInvalidHandle() {
646
+
l.Error("failed to resolve member identity to handle")
647
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
648
+
return
649
+
}
650
+
651
+
// remove from enforcer
652
+
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
653
+
if err != nil {
654
+
l.Error("failed to update ACLs", "err", err)
485
655
fail()
486
656
return
487
657
}
488
658
489
-
// success
490
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
659
+
client, err := k.OAuth.AuthorizedClient(r)
660
+
if err != nil {
661
+
l.Error("failed to authorize client", "err", err)
662
+
fail()
663
+
return
664
+
}
665
+
666
+
// TODO: We need to track the rkey for knot members to delete the record
667
+
// For now, just remove from ACLs
668
+
_ = client
669
+
670
+
// commit everything
671
+
err = k.Enforcer.E.SavePolicy()
672
+
if err != nil {
673
+
l.Error("failed to save ACLs", "err", err)
674
+
fail()
675
+
return
676
+
}
677
+
678
+
// ok
679
+
k.Pages.HxRefresh(w)
491
680
}
492
681
493
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
682
+
func (k *Knots) banner(w http.ResponseWriter, r *http.Request) {
683
+
user := k.OAuth.GetUser(r)
684
+
l := k.Logger.With("handler", "removeMember")
685
+
l = l.With("did", user.Did)
686
+
l = l.With("handle", user.Handle)
687
+
688
+
registrations, err := db.GetRegistrations(
689
+
k.Db,
690
+
db.FilterEq("did", user.Did),
691
+
db.FilterEq("read_only", 1),
692
+
)
693
+
if err != nil {
694
+
l.Error("non-fatal: failed to get registrations")
695
+
return
696
+
}
697
+
698
+
if registrations == nil {
699
+
return
700
+
}
701
+
702
+
k.Pages.KnotBanner(w, pages.KnotBannerParams{
703
+
Registrations: registrations,
704
+
})
494
705
}
+17
-14
appview/middleware/middleware.go
+17
-14
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"
···
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
}
···
183
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
184
191
if err != nil {
185
192
// invalid did or handle
186
-
log.Println("failed to resolve did/handle:", err)
193
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
187
194
mw.pages.Error404(w)
188
195
return
189
196
}
···
210
217
if err != nil {
211
218
// invalid did or handle
212
219
log.Println("failed to resolve repo")
213
-
mw.pages.Error404(w)
220
+
mw.pages.ErrorKnot404(w)
214
221
return
215
222
}
216
223
217
-
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
218
-
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
219
-
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
220
-
ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle)
221
-
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
224
+
ctx := context.WithValue(req.Context(), "repo", repo)
222
225
next.ServeHTTP(w, req.WithContext(ctx))
223
226
})
224
227
}
···
231
234
f, err := mw.repoResolver.Resolve(r)
232
235
if err != nil {
233
236
log.Println("failed to fully resolve repo", err)
234
-
http.Error(w, "invalid repo url", http.StatusNotFound)
237
+
mw.pages.ErrorKnot404(w)
235
238
return
236
239
}
237
240
···
243
246
return
244
247
}
245
248
246
-
pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt)
249
+
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
247
250
if err != nil {
248
251
log.Println("failed to get pull and comments", err)
249
252
return
···
280
283
f, err := mw.repoResolver.Resolve(r)
281
284
if err != nil {
282
285
log.Println("failed to fully resolve repo", err)
283
-
http.Error(w, "invalid repo url", http.StatusNotFound)
286
+
mw.pages.ErrorKnot404(w)
284
287
return
285
288
}
286
289
287
-
fullName := f.OwnerHandle() + "/" + f.RepoName
290
+
fullName := f.OwnerHandle() + "/" + f.Name
288
291
289
292
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
290
293
if r.URL.Query().Get("go-get") == "1" {
+124
-86
appview/oauth/handler/handler.go
+124
-86
appview/oauth/handler/handler.go
···
8
8
"log"
9
9
"net/http"
10
10
"net/url"
11
+
"slices"
11
12
"strings"
12
13
"time"
13
14
···
25
26
"tangled.sh/tangled.sh/core/appview/oauth/client"
26
27
"tangled.sh/tangled.sh/core/appview/pages"
27
28
"tangled.sh/tangled.sh/core/idresolver"
28
-
"tangled.sh/tangled.sh/core/knotclient"
29
29
"tangled.sh/tangled.sh/core/rbac"
30
30
"tangled.sh/tangled.sh/core/tid"
31
31
)
···
109
109
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
110
110
switch r.Method {
111
111
case http.MethodGet:
112
-
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
+
})
113
116
case http.MethodPost:
114
117
handle := r.FormValue("handle")
115
118
···
194
197
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
195
198
DpopPrivateJwk: string(dpopKeyJson),
196
199
State: parResp.State,
200
+
ReturnUrl: r.FormValue("return_url"),
197
201
})
198
202
if err != nil {
199
203
log.Println("failed to save oauth request:", err)
···
245
249
iss := r.FormValue("iss")
246
250
if iss == "" {
247
251
log.Println("missing iss for state: ", state)
252
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
253
+
return
254
+
}
255
+
256
+
if iss != oauthRequest.AuthserverIss {
257
+
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
248
258
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
249
259
return
250
260
}
···
311
321
}
312
322
}
313
323
314
-
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)
315
330
}
316
331
317
332
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
···
338
353
return pubKey, nil
339
354
}
340
355
356
+
var (
357
+
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
358
+
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
359
+
360
+
defaultSpindle = "spindle.tangled.sh"
361
+
defaultKnot = "knot1.tangled.sh"
362
+
)
363
+
341
364
func (o *OAuthHandler) addToDefaultSpindle(did string) {
342
365
// use the tangled.sh app password to get an accessJwt
343
366
// and create an sh.tangled.spindle.member record with that
344
-
345
-
defaultSpindle := "spindle.tangled.sh"
346
-
appPassword := o.config.Core.AppPassword
347
-
348
367
spindleMembers, err := db.GetSpindleMembers(
349
368
o.db,
350
369
db.FilterEq("instance", "spindle.tangled.sh"),
···
360
379
return
361
380
}
362
381
363
-
// TODO: hardcoded tangled handle and did for now
364
-
tangledHandle := "tangled.sh"
365
-
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
382
+
log.Printf("adding %s to default spindle", did)
383
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid)
384
+
if err != nil {
385
+
log.Printf("failed to create session: %s", err)
386
+
return
387
+
}
388
+
389
+
record := tangled.SpindleMember{
390
+
LexiconTypeID: "sh.tangled.spindle.member",
391
+
Subject: did,
392
+
Instance: defaultSpindle,
393
+
CreatedAt: time.Now().Format(time.RFC3339),
394
+
}
395
+
396
+
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
397
+
log.Printf("failed to add member to default spindle: %s", err)
398
+
return
399
+
}
400
+
401
+
log.Printf("successfully added %s to default spindle", did)
402
+
}
403
+
404
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
405
+
// use the tangled.sh app password to get an accessJwt
406
+
// and create an sh.tangled.spindle.member record with that
366
407
367
-
if appPassword == "" {
368
-
log.Println("no app password configured, skipping spindle member addition")
408
+
allKnots, err := o.enforcer.GetKnotsForUser(did)
409
+
if err != nil {
410
+
log.Printf("failed to get knot members for did %s: %v", did, err)
369
411
return
370
412
}
371
413
372
-
log.Printf("adding %s to default spindle", did)
414
+
if slices.Contains(allKnots, defaultKnot) {
415
+
log.Printf("did %s is already a member of the default knot", did)
416
+
return
417
+
}
373
418
374
-
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
419
+
log.Printf("adding %s to default knot", did)
420
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid)
375
421
if err != nil {
376
-
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
422
+
log.Printf("failed to create session: %s", err)
377
423
return
378
424
}
379
425
426
+
record := tangled.KnotMember{
427
+
LexiconTypeID: "sh.tangled.knot.member",
428
+
Subject: did,
429
+
Domain: defaultKnot,
430
+
CreatedAt: time.Now().Format(time.RFC3339),
431
+
}
432
+
433
+
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
434
+
log.Printf("failed to add member to default knot: %s", err)
435
+
return
436
+
}
437
+
438
+
if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil {
439
+
log.Printf("failed to set up enforcer rules: %s", err)
440
+
return
441
+
}
442
+
443
+
log.Printf("successfully added %s to default Knot", did)
444
+
}
445
+
446
+
// create a session using apppasswords
447
+
type session struct {
448
+
AccessJwt string `json:"accessJwt"`
449
+
PdsEndpoint string
450
+
Did string
451
+
}
452
+
453
+
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
454
+
if appPassword == "" {
455
+
return nil, fmt.Errorf("no app password configured, skipping member addition")
456
+
}
457
+
458
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), did)
459
+
if err != nil {
460
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
461
+
}
462
+
380
463
pdsEndpoint := resolved.PDSEndpoint()
381
464
if pdsEndpoint == "" {
382
-
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
383
-
return
465
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
384
466
}
385
467
386
468
sessionPayload := map[string]string{
387
-
"identifier": tangledHandle,
469
+
"identifier": did,
388
470
"password": appPassword,
389
471
}
390
472
sessionBytes, err := json.Marshal(sessionPayload)
391
473
if err != nil {
392
-
log.Printf("failed to marshal session payload: %v", err)
393
-
return
474
+
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
394
475
}
395
476
396
477
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
397
478
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
398
479
if err != nil {
399
-
log.Printf("failed to create session request: %v", err)
400
-
return
480
+
return nil, fmt.Errorf("failed to create session request: %v", err)
401
481
}
402
482
sessionReq.Header.Set("Content-Type", "application/json")
403
483
404
484
client := &http.Client{Timeout: 30 * time.Second}
405
485
sessionResp, err := client.Do(sessionReq)
406
486
if err != nil {
407
-
log.Printf("failed to create session: %v", err)
408
-
return
487
+
return nil, fmt.Errorf("failed to create session: %v", err)
409
488
}
410
489
defer sessionResp.Body.Close()
411
490
412
491
if sessionResp.StatusCode != http.StatusOK {
413
-
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
414
-
return
492
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
415
493
}
416
494
417
-
var session struct {
418
-
AccessJwt string `json:"accessJwt"`
419
-
}
495
+
var session session
420
496
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
421
-
log.Printf("failed to decode session response: %v", err)
422
-
return
497
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
423
498
}
424
499
425
-
record := tangled.SpindleMember{
426
-
LexiconTypeID: "sh.tangled.spindle.member",
427
-
Subject: did,
428
-
Instance: defaultSpindle,
429
-
CreatedAt: time.Now().Format(time.RFC3339),
430
-
}
500
+
session.PdsEndpoint = pdsEndpoint
501
+
session.Did = did
431
502
503
+
return &session, nil
504
+
}
505
+
506
+
func (s *session) putRecord(record any, collection string) error {
432
507
recordBytes, err := json.Marshal(record)
433
508
if err != nil {
434
-
log.Printf("failed to marshal spindle member record: %v", err)
435
-
return
509
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
436
510
}
437
511
438
-
payload := map[string]interface{}{
439
-
"repo": tangledDid,
440
-
"collection": tangled.SpindleMemberNSID,
512
+
payload := map[string]any{
513
+
"repo": s.Did,
514
+
"collection": collection,
441
515
"rkey": tid.TID(),
442
516
"record": json.RawMessage(recordBytes),
443
517
}
444
518
445
519
payloadBytes, err := json.Marshal(payload)
446
520
if err != nil {
447
-
log.Printf("failed to marshal request payload: %v", err)
448
-
return
521
+
return fmt.Errorf("failed to marshal request payload: %w", err)
449
522
}
450
523
451
-
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
524
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
452
525
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
453
526
if err != nil {
454
-
log.Printf("failed to create HTTP request: %v", err)
455
-
return
527
+
return fmt.Errorf("failed to create HTTP request: %w", err)
456
528
}
457
529
458
530
req.Header.Set("Content-Type", "application/json")
459
-
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
531
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
460
532
533
+
client := &http.Client{Timeout: 30 * time.Second}
461
534
resp, err := client.Do(req)
462
535
if err != nil {
463
-
log.Printf("failed to add user to default spindle: %v", err)
464
-
return
536
+
return fmt.Errorf("failed to add user to default service: %w", err)
465
537
}
466
538
defer resp.Body.Close()
467
539
468
540
if resp.StatusCode != http.StatusOK {
469
-
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
470
-
return
471
-
}
472
-
473
-
log.Printf("successfully added %s to default spindle", did)
474
-
}
475
-
476
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
477
-
defaultKnot := "knot1.tangled.sh"
478
-
479
-
log.Printf("adding %s to default knot", did)
480
-
err := o.enforcer.AddKnotMember(defaultKnot, did)
481
-
if err != nil {
482
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
483
-
return
484
-
}
485
-
err = o.enforcer.E.SavePolicy()
486
-
if err != nil {
487
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
488
-
return
489
-
}
490
-
491
-
secret, err := db.GetRegistrationKey(o.db, defaultKnot)
492
-
if err != nil {
493
-
log.Println("failed to get registration key for knot1.tangled.sh")
494
-
return
495
-
}
496
-
signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev)
497
-
resp, err := signedClient.AddMember(did)
498
-
if err != nil {
499
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
500
-
return
541
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
501
542
}
502
543
503
-
if resp.StatusCode != http.StatusNoContent {
504
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
505
-
return
506
-
}
544
+
return nil
507
545
}
+16
-3
appview/oauth/oauth.go
+16
-3
appview/oauth/oauth.go
···
103
103
if err != nil {
104
104
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
105
105
}
106
-
if expiry.Sub(time.Now()) <= 5*time.Minute {
106
+
if time.Until(expiry) <= 5*time.Minute {
107
107
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
108
108
if err != nil {
109
109
return nil, false, err
···
224
224
s.service = service
225
225
}
226
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
227
231
func WithExp(exp int64) ServiceClientOpt {
228
232
return func(s *ServiceClientOpts) {
229
-
s.exp = exp
233
+
s.exp = time.Now().Unix() + exp
230
234
}
231
235
}
232
236
···
266
270
return nil, err
267
271
}
268
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
+
269
279
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
270
280
if err != nil {
271
281
return nil, err
···
276
286
AccessJwt: resp.Token,
277
287
},
278
288
Host: opts.Host(),
289
+
Client: &http.Client{
290
+
Timeout: time.Second * 5,
291
+
},
279
292
}, nil
280
293
}
281
294
···
305
318
redirectURIs := makeRedirectURIs(clientURI)
306
319
307
320
if o.config.Core.Dev {
308
-
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
321
+
clientURI = "http://127.0.0.1:3000"
309
322
redirectURIs = makeRedirectURIs(clientURI)
310
323
311
324
query := url.Values{}
+35
appview/pages/cache.go
+35
appview/pages/cache.go
···
1
+
package pages
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type TmplCache[K comparable, V any] struct {
8
+
data map[K]V
9
+
mutex sync.RWMutex
10
+
}
11
+
12
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
13
+
return &TmplCache[K, V]{
14
+
data: make(map[K]V),
15
+
}
16
+
}
17
+
18
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
19
+
c.mutex.RLock()
20
+
defer c.mutex.RUnlock()
21
+
val, exists := c.data[key]
22
+
return val, exists
23
+
}
24
+
25
+
func (c *TmplCache[K, V]) Set(key K, value V) {
26
+
c.mutex.Lock()
27
+
defer c.mutex.Unlock()
28
+
c.data[key] = value
29
+
}
30
+
31
+
func (c *TmplCache[K, V]) Size() int {
32
+
c.mutex.RLock()
33
+
defer c.mutex.RUnlock()
34
+
return len(c.data)
35
+
}
+42
-6
appview/pages/funcmap.go
+42
-6
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"
···
18
19
19
20
"github.com/dustin/go-humanize"
20
21
"github.com/go-enry/go-enry/v2"
21
-
"github.com/microcosm-cc/bluemonday"
22
22
"tangled.sh/tangled.sh/core/appview/filetree"
23
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
+
"tangled.sh/tangled.sh/core/crypto"
24
25
)
25
26
26
27
func (p *Pages) funcMap() template.FuncMap {
···
28
29
"split": func(s string) []string {
29
30
return strings.Split(s, "\n")
30
31
},
32
+
"resolve": func(s string) string {
33
+
identity, err := p.resolver.ResolveIdent(context.Background(), s)
34
+
35
+
if err != nil {
36
+
return s
37
+
}
38
+
39
+
if identity.Handle.IsInvalidHandle() {
40
+
return "handle.invalid"
41
+
}
42
+
43
+
return "@" + identity.Handle.String()
44
+
},
31
45
"truncateAt30": func(s string) string {
32
46
if len(s) <= 30 {
33
47
return s
···
74
88
"negf64": func(a float64) float64 {
75
89
return -a
76
90
},
77
-
"cond": func(cond interface{}, a, b string) string {
91
+
"cond": func(cond any, a, b string) string {
78
92
if cond == nil {
79
93
return b
80
94
}
···
167
181
return html.UnescapeString(s)
168
182
},
169
183
"nl2br": func(text string) template.HTML {
170
-
return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1))
184
+
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
171
185
},
172
186
"unwrapText": func(text string) string {
173
187
paragraphs := strings.Split(text, "\n\n")
···
193
207
}
194
208
return v.Slice(0, min(n, v.Len())).Interface()
195
209
},
196
-
197
210
"markdown": func(text string) template.HTML {
198
-
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
199
-
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
211
+
p.rctx.RendererType = markup.RendererTypeDefault
212
+
htmlString := p.rctx.RenderMarkdown(text)
213
+
sanitized := p.rctx.SanitizeDefault(htmlString)
214
+
return template.HTML(sanitized)
215
+
},
216
+
"description": func(text string) template.HTML {
217
+
p.rctx.RendererType = markup.RendererTypeDefault
218
+
htmlString := p.rctx.RenderMarkdown(text)
219
+
sanitized := p.rctx.SanitizeDescription(htmlString)
220
+
return template.HTML(sanitized)
200
221
},
201
222
"isNil": func(t any) bool {
202
223
// returns false for other "zero" values
···
236
257
},
237
258
"cssContentHash": CssContentHash,
238
259
"fileTree": filetree.FileTree,
260
+
"pathEscape": func(s string) string {
261
+
return url.PathEscape(s)
262
+
},
239
263
"pathUnescape": func(s string) string {
240
264
u, _ := url.PathUnescape(s)
241
265
return u
···
253
277
},
254
278
"layoutCenter": func() string {
255
279
return "col-span-1 md:col-span-8 lg:col-span-6"
280
+
},
281
+
282
+
"normalizeForHtmlId": func(s string) string {
283
+
// TODO: extend this to handle other cases?
284
+
return strings.ReplaceAll(s, ":", "_")
285
+
},
286
+
"sshFingerprint": func(pubKey string) string {
287
+
fp, err := crypto.SSHFingerprint(pubKey)
288
+
if err != nil {
289
+
return "error"
290
+
}
291
+
return fp
256
292
},
257
293
}
258
294
}
+63
-31
appview/pages/markup/markdown.go
+63
-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"
14
+
treeblood "github.com/wyatt915/goldmark-treeblood"
13
15
"github.com/yuin/goldmark"
16
+
highlighting "github.com/yuin/goldmark-highlighting/v2"
14
17
"github.com/yuin/goldmark/ast"
15
18
"github.com/yuin/goldmark/extension"
16
19
"github.com/yuin/goldmark/parser"
···
40
43
repoinfo.RepoInfo
41
44
IsDev bool
42
45
RendererType RendererType
46
+
Sanitizer Sanitizer
43
47
}
44
48
45
49
func (rctx *RenderContext) RenderMarkdown(source string) string {
46
50
md := goldmark.New(
47
-
goldmark.WithExtensions(extension.GFM),
51
+
goldmark.WithExtensions(
52
+
extension.GFM,
53
+
highlighting.NewHighlighting(
54
+
highlighting.WithFormatOptions(
55
+
chromahtml.Standalone(false),
56
+
chromahtml.WithClasses(true),
57
+
),
58
+
highlighting.WithCustomStyle(styles.Get("catppuccin-latte")),
59
+
),
60
+
extension.NewFootnote(
61
+
extension.WithFootnoteIDPrefix([]byte("footnote")),
62
+
),
63
+
treeblood.MathML(),
64
+
),
48
65
goldmark.WithParserOptions(
49
66
parser.WithAutoHeadingID(),
50
67
),
···
145
162
}
146
163
}
147
164
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")
165
+
func (rctx *RenderContext) SanitizeDefault(html string) string {
166
+
return rctx.Sanitizer.SanitizeDefault(html)
167
+
}
159
168
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)
169
+
func (rctx *RenderContext) SanitizeDescription(html string) string {
170
+
return rctx.Sanitizer.SanitizeDescription(html)
177
171
}
178
172
179
173
type MarkdownTransformer struct {
···
189
183
switch a.rctx.RendererType {
190
184
case RendererTypeRepoMarkdown:
191
185
switch n := n.(type) {
186
+
case *ast.Heading:
187
+
a.rctx.anchorHeadingTransformer(n)
192
188
case *ast.Link:
193
189
a.rctx.relativeLinkTransformer(n)
194
190
case *ast.Image:
···
197
193
}
198
194
case RendererTypeDefault:
199
195
switch n := n.(type) {
196
+
case *ast.Heading:
197
+
a.rctx.anchorHeadingTransformer(n)
200
198
case *ast.Image:
201
199
a.rctx.imageFromKnotAstTransformer(n)
202
200
a.rctx.camoImageLinkAstTransformer(n)
···
211
209
212
210
dst := string(link.Destination)
213
211
214
-
if isAbsoluteUrl(dst) {
212
+
if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) {
215
213
return
216
214
}
217
215
···
252
250
img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
253
251
}
254
252
253
+
func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) {
254
+
idGeneric, exists := h.AttributeString("id")
255
+
if !exists {
256
+
return // no id, nothing to do
257
+
}
258
+
id, ok := idGeneric.([]byte)
259
+
if !ok {
260
+
return
261
+
}
262
+
263
+
// create anchor link
264
+
anchor := ast.NewLink()
265
+
anchor.Destination = fmt.Appendf(nil, "#%s", string(id))
266
+
anchor.SetAttribute([]byte("class"), []byte("anchor"))
267
+
268
+
// create icon text
269
+
iconText := ast.NewString([]byte("#"))
270
+
anchor.AppendChild(anchor, iconText)
271
+
272
+
// set class on heading
273
+
h.SetAttribute([]byte("class"), []byte("heading"))
274
+
275
+
// append anchor to heading
276
+
h.AppendChild(h, anchor)
277
+
}
278
+
255
279
// actualPath decides when to join the file path with the
256
280
// current repository directory (essentially only when the link
257
281
// destination is relative. if it's absolute then we assume the
···
271
295
}
272
296
return parsed.IsAbs()
273
297
}
298
+
299
+
func isFragment(link string) bool {
300
+
return strings.HasPrefix(link, "#")
301
+
}
302
+
303
+
func isMail(link string) bool {
304
+
return strings.HasPrefix(link, "mailto:")
305
+
}
+134
appview/pages/markup/sanitizer.go
+134
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
+
// math
101
+
mathAttrs := []string{
102
+
"accent", "columnalign", "columnlines", "columnspan", "dir", "display",
103
+
"displaystyle", "encoding", "fence", "form", "largeop", "linebreak",
104
+
"linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize",
105
+
"movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan",
106
+
"scriptlevel", "stretchy", "symmetric", "title", "voffset", "width",
107
+
}
108
+
mathElements := []string{
109
+
"annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts",
110
+
"mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace",
111
+
"msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext",
112
+
"mtr", "munder", "munderover", "semantics",
113
+
}
114
+
policy.AllowNoAttrs().OnElements(mathElements...)
115
+
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
116
+
117
+
return policy
118
+
}
119
+
120
+
func descriptionPolicy() *bluemonday.Policy {
121
+
policy := bluemonday.NewPolicy()
122
+
policy.AllowStandardURLs()
123
+
124
+
// allow italics and bold.
125
+
policy.AllowElements("i", "b", "em", "strong")
126
+
127
+
// allow code.
128
+
policy.AllowElements("code")
129
+
130
+
// allow links
131
+
policy.AllowAttrs("href", "target", "rel").OnElements("a")
132
+
133
+
return policy
134
+
}
+317
-230
appview/pages/pages.go
+317
-230
appview/pages/pages.go
···
9
9
"html/template"
10
10
"io"
11
11
"io/fs"
12
-
"log"
12
+
"log/slog"
13
13
"net/http"
14
14
"os"
15
15
"path/filepath"
···
24
24
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
25
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
26
"tangled.sh/tangled.sh/core/appview/pagination"
27
+
"tangled.sh/tangled.sh/core/idresolver"
27
28
"tangled.sh/tangled.sh/core/patchutil"
28
29
"tangled.sh/tangled.sh/core/types"
29
30
···
41
42
var Files embed.FS
42
43
43
44
type Pages struct {
44
-
mu sync.RWMutex
45
-
t map[string]*template.Template
45
+
mu sync.RWMutex
46
+
cache *TmplCache[string, *template.Template]
46
47
47
48
avatar config.AvatarConfig
49
+
resolver *idresolver.Resolver
48
50
dev bool
49
-
embedFS embed.FS
51
+
embedFS fs.FS
50
52
templateDir string // Path to templates on disk for dev mode
51
53
rctx *markup.RenderContext
54
+
logger *slog.Logger
52
55
}
53
56
54
-
func NewPages(config *config.Config) *Pages {
57
+
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
55
58
// initialized with safe defaults, can be overriden per use
56
59
rctx := &markup.RenderContext{
57
60
IsDev: config.Core.Dev,
58
61
CamoUrl: config.Camo.Host,
59
62
CamoSecret: config.Camo.SharedSecret,
63
+
Sanitizer: markup.NewSanitizer(),
60
64
}
61
65
62
66
p := &Pages{
63
67
mu: sync.RWMutex{},
64
-
t: make(map[string]*template.Template),
68
+
cache: NewTmplCache[string, *template.Template](),
65
69
dev: config.Core.Dev,
66
70
avatar: config.Avatar,
67
-
embedFS: Files,
68
71
rctx: rctx,
72
+
resolver: res,
69
73
templateDir: "appview/pages",
74
+
logger: slog.Default().With("component", "pages"),
70
75
}
71
76
72
-
// Initial load of all templates
73
-
p.loadAllTemplates()
77
+
if p.dev {
78
+
p.embedFS = os.DirFS(p.templateDir)
79
+
} else {
80
+
p.embedFS = Files
81
+
}
74
82
75
83
return p
76
84
}
77
85
78
-
func (p *Pages) loadAllTemplates() {
79
-
templates := make(map[string]*template.Template)
86
+
func (p *Pages) pathToName(s string) string {
87
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
+
}
89
+
90
+
// reverse of pathToName
91
+
func (p *Pages) nameToPath(s string) string {
92
+
return "templates/" + s + ".html"
93
+
}
94
+
95
+
func (p *Pages) fragmentPaths() ([]string, error) {
80
96
var fragmentPaths []string
81
-
82
-
// Use embedded FS for initial loading
83
-
// First, collect all fragment paths
84
97
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
85
98
if err != nil {
86
99
return err
···
94
107
if !strings.Contains(path, "fragments/") {
95
108
return nil
96
109
}
97
-
name := strings.TrimPrefix(path, "templates/")
98
-
name = strings.TrimSuffix(name, ".html")
99
-
tmpl, err := template.New(name).
100
-
Funcs(p.funcMap()).
101
-
ParseFS(p.embedFS, path)
102
-
if err != nil {
103
-
log.Fatalf("setting up fragment: %v", err)
104
-
}
105
-
templates[name] = tmpl
106
110
fragmentPaths = append(fragmentPaths, path)
107
-
log.Printf("loaded fragment: %s", name)
108
111
return nil
109
112
})
110
113
if err != nil {
111
-
log.Fatalf("walking template dir for fragments: %v", err)
114
+
return nil, err
112
115
}
113
116
114
-
// Then walk through and setup the rest of the templates
115
-
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
117
+
return fragmentPaths, nil
118
+
}
119
+
120
+
func (p *Pages) fragments() (*template.Template, error) {
121
+
fragmentPaths, err := p.fragmentPaths()
122
+
if err != nil {
123
+
return nil, err
124
+
}
125
+
126
+
funcs := p.funcMap()
127
+
128
+
// parse all fragments together
129
+
allFragments := template.New("").Funcs(funcs)
130
+
for _, f := range fragmentPaths {
131
+
name := p.pathToName(f)
132
+
133
+
pf, err := template.New(name).
134
+
Funcs(funcs).
135
+
ParseFS(p.embedFS, f)
116
136
if err != nil {
117
-
return err
118
-
}
119
-
if d.IsDir() {
120
-
return nil
121
-
}
122
-
if !strings.HasSuffix(path, "html") {
123
-
return nil
124
-
}
125
-
// Skip fragments as they've already been loaded
126
-
if strings.Contains(path, "fragments/") {
127
-
return nil
128
-
}
129
-
// Skip layouts
130
-
if strings.Contains(path, "layouts/") {
131
-
return nil
137
+
return nil, err
132
138
}
133
-
name := strings.TrimPrefix(path, "templates/")
134
-
name = strings.TrimSuffix(name, ".html")
135
-
// Add the page template on top of the base
136
-
allPaths := []string{}
137
-
allPaths = append(allPaths, "templates/layouts/*.html")
138
-
allPaths = append(allPaths, fragmentPaths...)
139
-
allPaths = append(allPaths, path)
140
-
tmpl, err := template.New(name).
141
-
Funcs(p.funcMap()).
142
-
ParseFS(p.embedFS, allPaths...)
139
+
140
+
allFragments, err = allFragments.AddParseTree(name, pf.Tree)
143
141
if err != nil {
144
-
return fmt.Errorf("setting up template: %w", err)
142
+
return nil, err
145
143
}
146
-
templates[name] = tmpl
147
-
log.Printf("loaded template: %s", name)
148
-
return nil
149
-
})
150
-
if err != nil {
151
-
log.Fatalf("walking template dir: %v", err)
152
144
}
153
145
154
-
log.Printf("total templates loaded: %d", len(templates))
155
-
p.mu.Lock()
156
-
defer p.mu.Unlock()
157
-
p.t = templates
146
+
return allFragments, nil
158
147
}
159
148
160
-
// loadTemplateFromDisk loads a template from the filesystem in dev mode
161
-
func (p *Pages) loadTemplateFromDisk(name string) error {
162
-
if !p.dev {
163
-
return nil
149
+
// parse without memoization
150
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
151
+
paths, err := p.fragmentPaths()
152
+
if err != nil {
153
+
return nil, err
164
154
}
165
-
166
-
log.Printf("reloading template from disk: %s", name)
155
+
for _, s := range stack {
156
+
paths = append(paths, p.nameToPath(s))
157
+
}
167
158
168
-
// Find all fragments first
169
-
var fragmentPaths []string
170
-
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
171
-
if err != nil {
172
-
return err
173
-
}
174
-
if d.IsDir() {
175
-
return nil
176
-
}
177
-
if !strings.HasSuffix(path, ".html") {
178
-
return nil
179
-
}
180
-
if !strings.Contains(path, "fragments/") {
181
-
return nil
182
-
}
183
-
fragmentPaths = append(fragmentPaths, path)
184
-
return nil
185
-
})
159
+
funcs := p.funcMap()
160
+
top := stack[len(stack)-1]
161
+
parsed, err := template.New(top).
162
+
Funcs(funcs).
163
+
ParseFS(p.embedFS, paths...)
186
164
if err != nil {
187
-
return fmt.Errorf("walking disk template dir for fragments: %w", err)
165
+
return nil, err
188
166
}
189
167
190
-
// Find the template path on disk
191
-
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
192
-
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
193
-
return fmt.Errorf("template not found on disk: %s", name)
168
+
return parsed, nil
169
+
}
170
+
171
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
172
+
key := strings.Join(stack, "|")
173
+
174
+
// never cache in dev mode
175
+
if cached, exists := p.cache.Get(key); !p.dev && exists {
176
+
return cached, nil
194
177
}
195
178
196
-
// Create a new template
197
-
tmpl := template.New(name).Funcs(p.funcMap())
198
-
199
-
// Parse layouts
200
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
201
-
layouts, err := filepath.Glob(layoutGlob)
179
+
result, err := p.rawParse(stack...)
202
180
if err != nil {
203
-
return fmt.Errorf("finding layout templates: %w", err)
181
+
return nil, err
204
182
}
205
183
206
-
// Create paths for parsing
207
-
allFiles := append(layouts, fragmentPaths...)
208
-
allFiles = append(allFiles, templatePath)
184
+
p.cache.Set(key, result)
185
+
return result, nil
186
+
}
209
187
210
-
// Parse all templates
211
-
tmpl, err = tmpl.ParseFiles(allFiles...)
212
-
if err != nil {
213
-
return fmt.Errorf("parsing template files: %w", err)
188
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
189
+
stack := []string{
190
+
"layouts/base",
191
+
top,
214
192
}
193
+
return p.parse(stack...)
194
+
}
215
195
216
-
// Update the template in the map
217
-
p.mu.Lock()
218
-
defer p.mu.Unlock()
219
-
p.t[name] = tmpl
220
-
log.Printf("template reloaded from disk: %s", name)
221
-
return nil
196
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
197
+
stack := []string{
198
+
"layouts/base",
199
+
"layouts/repobase",
200
+
top,
201
+
}
202
+
return p.parse(stack...)
222
203
}
223
204
224
-
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
225
-
// In dev mode, reload the template from disk before executing
226
-
if p.dev {
227
-
if err := p.loadTemplateFromDisk(templateName); err != nil {
228
-
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
229
-
// Continue with the existing template
230
-
}
205
+
func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
206
+
stack := []string{
207
+
"layouts/base",
208
+
"layouts/profilebase",
209
+
top,
231
210
}
211
+
return p.parse(stack...)
212
+
}
232
213
233
-
p.mu.RLock()
234
-
defer p.mu.RUnlock()
235
-
tmpl, exists := p.t[templateName]
236
-
if !exists {
237
-
return fmt.Errorf("template not found: %s", templateName)
214
+
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
215
+
tpl, err := p.parse(name)
216
+
if err != nil {
217
+
return err
238
218
}
239
219
240
-
if base == "" {
241
-
return tmpl.Execute(w, params)
242
-
} else {
243
-
return tmpl.ExecuteTemplate(w, base, params)
244
-
}
220
+
return tpl.Execute(w, params)
245
221
}
246
222
247
223
func (p *Pages) execute(name string, w io.Writer, params any) error {
248
-
return p.executeOrReload(name, w, "layouts/base", params)
249
-
}
224
+
tpl, err := p.parseBase(name)
225
+
if err != nil {
226
+
return err
227
+
}
250
228
251
-
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
252
-
return p.executeOrReload(name, w, "", params)
229
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
253
230
}
254
231
255
232
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
256
-
return p.executeOrReload(name, w, "layouts/repobase", params)
233
+
tpl, err := p.parseRepoBase(name)
234
+
if err != nil {
235
+
return err
236
+
}
237
+
238
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
239
+
}
240
+
241
+
func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
242
+
tpl, err := p.parseProfileBase(name)
243
+
if err != nil {
244
+
return err
245
+
}
246
+
247
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
248
+
}
249
+
250
+
func (p *Pages) Favicon(w io.Writer) error {
251
+
return p.executePlain("favicon", w, nil)
257
252
}
258
253
259
254
type LoginParams struct {
255
+
ReturnUrl string
260
256
}
261
257
262
258
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
290
286
type TimelineParams struct {
291
287
LoggedInUser *oauth.User
292
288
Timeline []db.TimelineEvent
293
-
DidHandleMap map[string]string
289
+
Repos []db.Repo
294
290
}
295
291
296
292
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
297
-
return p.execute("timeline", w, params)
293
+
return p.execute("timeline/timeline", w, params)
294
+
}
295
+
296
+
type UserProfileSettingsParams struct {
297
+
LoggedInUser *oauth.User
298
+
Tabs []map[string]any
299
+
Tab string
300
+
}
301
+
302
+
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
303
+
return p.execute("user/settings/profile", w, params)
298
304
}
299
305
300
-
type SettingsParams struct {
306
+
type UserKeysSettingsParams struct {
301
307
LoggedInUser *oauth.User
302
308
PubKeys []db.PublicKey
309
+
Tabs []map[string]any
310
+
Tab string
311
+
}
312
+
313
+
func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
314
+
return p.execute("user/settings/keys", w, params)
315
+
}
316
+
317
+
type UserEmailsSettingsParams struct {
318
+
LoggedInUser *oauth.User
303
319
Emails []db.Email
320
+
Tabs []map[string]any
321
+
Tab string
304
322
}
305
323
306
-
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
307
-
return p.execute("settings", w, params)
324
+
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
325
+
return p.execute("user/settings/emails", w, params)
326
+
}
327
+
328
+
type KnotBannerParams struct {
329
+
Registrations []db.Registration
330
+
}
331
+
332
+
func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error {
333
+
return p.executePlain("knots/fragments/banner", w, params)
308
334
}
309
335
310
336
type KnotsParams struct {
···
318
344
319
345
type KnotParams struct {
320
346
LoggedInUser *oauth.User
321
-
DidHandleMap map[string]string
322
347
Registration *db.Registration
323
348
Members []string
324
349
Repos map[string][]db.Repo
···
330
355
}
331
356
332
357
type KnotListingParams struct {
333
-
db.Registration
358
+
*db.Registration
334
359
}
335
360
336
361
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
337
362
return p.executePlain("knots/fragments/knotListing", w, params)
338
363
}
339
364
340
-
type KnotListingFullParams struct {
341
-
Registrations []db.Registration
342
-
}
343
-
344
-
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
345
-
return p.executePlain("knots/fragments/knotListingFull", w, params)
346
-
}
347
-
348
-
type KnotSecretParams struct {
349
-
Secret string
350
-
}
351
-
352
-
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
353
-
return p.executePlain("knots/fragments/secret", w, params)
354
-
}
355
-
356
365
type SpindlesParams struct {
357
366
LoggedInUser *oauth.User
358
367
Spindles []db.Spindle
···
375
384
Spindle db.Spindle
376
385
Members []string
377
386
Repos map[string][]db.Repo
378
-
DidHandleMap map[string]string
379
387
}
380
388
381
389
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
401
409
return p.execute("repo/fork", w, params)
402
410
}
403
411
404
-
type ProfilePageParams struct {
412
+
type ProfileCard struct {
413
+
UserDid string
414
+
UserHandle string
415
+
FollowStatus db.FollowStatus
416
+
Punchcard *db.Punchcard
417
+
Profile *db.Profile
418
+
Stats ProfileStats
419
+
Active string
420
+
}
421
+
422
+
type ProfileStats struct {
423
+
RepoCount int64
424
+
StarredCount int64
425
+
StringCount int64
426
+
FollowersCount int64
427
+
FollowingCount int64
428
+
}
429
+
430
+
func (p *ProfileCard) GetTabs() [][]any {
431
+
tabs := [][]any{
432
+
{"overview", "overview", "square-chart-gantt", nil},
433
+
{"repos", "repos", "book-marked", p.Stats.RepoCount},
434
+
{"starred", "starred", "star", p.Stats.StarredCount},
435
+
{"strings", "strings", "line-squiggle", p.Stats.StringCount},
436
+
}
437
+
438
+
return tabs
439
+
}
440
+
441
+
type ProfileOverviewParams struct {
405
442
LoggedInUser *oauth.User
406
443
Repos []db.Repo
407
444
CollaboratingRepos []db.Repo
408
445
ProfileTimeline *db.ProfileTimeline
409
-
Card ProfileCard
410
-
Punchcard db.Punchcard
446
+
Card *ProfileCard
447
+
Active string
448
+
}
411
449
412
-
DidHandleMap map[string]string
450
+
func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
451
+
params.Active = "overview"
452
+
return p.executeProfile("user/overview", w, params)
413
453
}
414
454
415
-
type ProfileCard struct {
416
-
UserDid string
417
-
UserHandle string
418
-
FollowStatus db.FollowStatus
419
-
Followers int
420
-
Following int
455
+
type ProfileReposParams struct {
456
+
LoggedInUser *oauth.User
457
+
Repos []db.Repo
458
+
Card *ProfileCard
459
+
Active string
460
+
}
421
461
422
-
Profile *db.Profile
462
+
func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
463
+
params.Active = "repos"
464
+
return p.executeProfile("user/repos", w, params)
423
465
}
424
466
425
-
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
426
-
return p.execute("user/profile", w, params)
467
+
type ProfileStarredParams struct {
468
+
LoggedInUser *oauth.User
469
+
Repos []db.Repo
470
+
Card *ProfileCard
471
+
Active string
472
+
}
473
+
474
+
func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
475
+
params.Active = "starred"
476
+
return p.executeProfile("user/starred", w, params)
477
+
}
478
+
479
+
type ProfileStringsParams struct {
480
+
LoggedInUser *oauth.User
481
+
Strings []db.String
482
+
Card *ProfileCard
483
+
Active string
484
+
}
485
+
486
+
func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
487
+
params.Active = "strings"
488
+
return p.executeProfile("user/strings", w, params)
489
+
}
490
+
491
+
type FollowCard struct {
492
+
UserDid string
493
+
FollowStatus db.FollowStatus
494
+
FollowersCount int64
495
+
FollowingCount int64
496
+
Profile *db.Profile
427
497
}
428
498
429
-
type ReposPageParams struct {
499
+
type ProfileFollowersParams struct {
430
500
LoggedInUser *oauth.User
431
-
Repos []db.Repo
432
-
Card ProfileCard
501
+
Followers []FollowCard
502
+
Card *ProfileCard
503
+
Active string
504
+
}
433
505
434
-
DidHandleMap map[string]string
506
+
func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
507
+
params.Active = "overview"
508
+
return p.executeProfile("user/followers", w, params)
435
509
}
436
510
437
-
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
438
-
return p.execute("user/repos", w, params)
511
+
type ProfileFollowingParams struct {
512
+
LoggedInUser *oauth.User
513
+
Following []FollowCard
514
+
Card *ProfileCard
515
+
Active string
516
+
}
517
+
518
+
func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
519
+
params.Active = "overview"
520
+
return p.executeProfile("user/following", w, params)
439
521
}
440
522
441
523
type FollowFragmentParams struct {
···
460
542
LoggedInUser *oauth.User
461
543
Profile *db.Profile
462
544
AllRepos []PinnedRepo
463
-
DidHandleMap map[string]string
464
545
}
465
546
466
547
type PinnedRepo struct {
···
495
576
}
496
577
497
578
type RepoIndexParams struct {
498
-
LoggedInUser *oauth.User
499
-
RepoInfo repoinfo.RepoInfo
500
-
Active string
501
-
TagMap map[string][]string
502
-
CommitsTrunc []*object.Commit
503
-
TagsTrunc []*types.TagReference
504
-
BranchesTrunc []types.Branch
505
-
ForkInfo *types.ForkInfo
579
+
LoggedInUser *oauth.User
580
+
RepoInfo repoinfo.RepoInfo
581
+
Active string
582
+
TagMap map[string][]string
583
+
CommitsTrunc []*object.Commit
584
+
TagsTrunc []*types.TagReference
585
+
BranchesTrunc []types.Branch
586
+
// ForkInfo *types.ForkInfo
506
587
HTMLReadme template.HTML
507
588
Raw bool
508
589
EmailToDidOrHandle map[string]string
···
519
600
}
520
601
521
602
p.rctx.RepoInfo = params.RepoInfo
603
+
p.rctx.RepoInfo.Ref = params.Ref
522
604
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
523
605
524
606
if params.ReadmeFileName != "" {
525
-
var htmlString string
526
607
ext := filepath.Ext(params.ReadmeFileName)
527
608
switch ext {
528
609
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
529
-
htmlString = p.rctx.Sanitize(htmlString)
530
-
htmlString = p.rctx.RenderMarkdown(params.Readme)
531
610
params.Raw = false
532
-
params.HTMLReadme = template.HTML(htmlString)
611
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
612
+
sanitized := p.rctx.SanitizeDefault(htmlString)
613
+
params.HTMLReadme = template.HTML(sanitized)
533
614
default:
534
615
params.Raw = true
535
616
}
···
605
686
606
687
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
607
688
params.Active = "overview"
608
-
return p.execute("repo/tree", w, params)
689
+
return p.executeRepo("repo/tree", w, params)
609
690
}
610
691
611
692
type RepoBranchesParams struct {
···
668
749
p.rctx.RepoInfo = params.RepoInfo
669
750
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
670
751
htmlString := p.rctx.RenderMarkdown(params.Contents)
671
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
752
+
sanitized := p.rctx.SanitizeDefault(htmlString)
753
+
params.RenderedContents = template.HTML(sanitized)
672
754
}
673
755
}
674
756
675
-
if params.Lines < 5000 {
676
-
c := params.Contents
677
-
formatter := chromahtml.New(
678
-
chromahtml.InlineCode(false),
679
-
chromahtml.WithLineNumbers(true),
680
-
chromahtml.WithLinkableLineNumbers(true, "L"),
681
-
chromahtml.Standalone(false),
682
-
chromahtml.WithClasses(true),
683
-
)
684
-
685
-
lexer := lexers.Get(filepath.Base(params.Path))
686
-
if lexer == nil {
687
-
lexer = lexers.Fallback
688
-
}
757
+
c := params.Contents
758
+
formatter := chromahtml.New(
759
+
chromahtml.InlineCode(false),
760
+
chromahtml.WithLineNumbers(true),
761
+
chromahtml.WithLinkableLineNumbers(true, "L"),
762
+
chromahtml.Standalone(false),
763
+
chromahtml.WithClasses(true),
764
+
)
689
765
690
-
iterator, err := lexer.Tokenise(nil, c)
691
-
if err != nil {
692
-
return fmt.Errorf("chroma tokenize: %w", err)
693
-
}
766
+
lexer := lexers.Get(filepath.Base(params.Path))
767
+
if lexer == nil {
768
+
lexer = lexers.Fallback
769
+
}
694
770
695
-
var code bytes.Buffer
696
-
err = formatter.Format(&code, style, iterator)
697
-
if err != nil {
698
-
return fmt.Errorf("chroma format: %w", err)
699
-
}
771
+
iterator, err := lexer.Tokenise(nil, c)
772
+
if err != nil {
773
+
return fmt.Errorf("chroma tokenize: %w", err)
774
+
}
700
775
701
-
params.Contents = code.String()
776
+
var code bytes.Buffer
777
+
err = formatter.Format(&code, style, iterator)
778
+
if err != nil {
779
+
return fmt.Errorf("chroma format: %w", err)
702
780
}
703
781
782
+
params.Contents = code.String()
704
783
params.Active = "overview"
705
784
return p.executeRepo("repo/blob", w, params)
706
785
}
···
779
858
RepoInfo repoinfo.RepoInfo
780
859
Active string
781
860
Issues []db.Issue
782
-
DidHandleMap map[string]string
783
861
Page pagination.Page
784
862
FilteringByOpen bool
785
863
}
···
793
871
LoggedInUser *oauth.User
794
872
RepoInfo repoinfo.RepoInfo
795
873
Active string
796
-
Issue db.Issue
874
+
Issue *db.Issue
797
875
Comments []db.Comment
798
876
IssueOwnerHandle string
799
-
DidHandleMap map[string]string
800
877
801
878
OrderedReactionKinds []db.ReactionKind
802
879
Reactions map[db.ReactionKind]int
···
823
900
} else {
824
901
params.State = "closed"
825
902
}
826
-
return p.execute("repo/issues/issue", w, params)
903
+
return p.executeRepo("repo/issues/issue", w, params)
827
904
}
828
905
829
906
type RepoNewIssueParams struct {
···
850
927
851
928
type SingleIssueCommentParams struct {
852
929
LoggedInUser *oauth.User
853
-
DidHandleMap map[string]string
854
930
RepoInfo repoinfo.RepoInfo
855
931
Issue *db.Issue
856
932
Comment *db.Comment
···
882
958
RepoInfo repoinfo.RepoInfo
883
959
Pulls []*db.Pull
884
960
Active string
885
-
DidHandleMap map[string]string
886
961
FilteringBy db.PullState
887
962
Stacks map[string]db.Stack
888
963
Pipelines map[string]db.Pipeline
···
915
990
LoggedInUser *oauth.User
916
991
RepoInfo repoinfo.RepoInfo
917
992
Active string
918
-
DidHandleMap map[string]string
919
993
Pull *db.Pull
920
994
Stack db.Stack
921
995
AbandonedPulls []*db.Pull
···
935
1009
936
1010
type RepoPullPatchParams struct {
937
1011
LoggedInUser *oauth.User
938
-
DidHandleMap map[string]string
939
1012
RepoInfo repoinfo.RepoInfo
940
1013
Pull *db.Pull
941
1014
Stack db.Stack
···
953
1026
954
1027
type RepoPullInterdiffParams struct {
955
1028
LoggedInUser *oauth.User
956
-
DidHandleMap map[string]string
957
1029
RepoInfo repoinfo.RepoInfo
958
1030
Pull *db.Pull
959
1031
Round int
···
1166
1238
return p.execute("strings/dashboard", w, params)
1167
1239
}
1168
1240
1241
+
type StringTimelineParams struct {
1242
+
LoggedInUser *oauth.User
1243
+
Strings []db.String
1244
+
}
1245
+
1246
+
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1247
+
return p.execute("strings/timeline", w, params)
1248
+
}
1249
+
1169
1250
type SingleStringParams struct {
1170
1251
LoggedInUser *oauth.User
1171
1252
ShowRendered bool
···
1182
1263
if params.ShowRendered {
1183
1264
switch markup.GetFormat(params.String.Filename) {
1184
1265
case markup.FormatMarkdown:
1185
-
p.rctx.RendererType = markup.RendererTypeDefault
1266
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1186
1267
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1187
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
1268
+
sanitized := p.rctx.SanitizeDefault(htmlString)
1269
+
params.RenderedContents = template.HTML(sanitized)
1188
1270
}
1189
1271
}
1190
1272
···
1224
1306
1225
1307
sub, err := fs.Sub(Files, "static")
1226
1308
if err != nil {
1227
-
log.Fatalf("no static dir found? that's crazy: %v", err)
1309
+
p.logger.Error("no static dir found? that's crazy", "err", err)
1310
+
panic(err)
1228
1311
}
1229
1312
// Custom handler to apply Cache-Control headers for font files
1230
1313
return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
···
1247
1330
func CssContentHash() string {
1248
1331
cssFile, err := Files.Open("static/tw.css")
1249
1332
if err != nil {
1250
-
log.Printf("Error opening CSS file: %v", err)
1333
+
slog.Debug("Error opening CSS file", "err", err)
1251
1334
return ""
1252
1335
}
1253
1336
defer cssFile.Close()
1254
1337
1255
1338
hasher := sha256.New()
1256
1339
if _, err := io.Copy(hasher, cssFile); err != nil {
1257
-
log.Printf("Error hashing CSS file: %v", err)
1340
+
slog.Debug("Error hashing CSS file", "err", err)
1258
1341
return ""
1259
1342
}
1260
1343
···
1267
1350
1268
1351
func (p *Pages) Error404(w io.Writer) error {
1269
1352
return p.execute("errors/404", w, nil)
1353
+
}
1354
+
1355
+
func (p *Pages) ErrorKnot404(w io.Writer) error {
1356
+
return p.execute("errors/knot404", w, nil)
1270
1357
}
1271
1358
1272
1359
func (p *Pages) Error503(w io.Writer) error {
+2
-7
appview/pages/repoinfo/repoinfo.go
+2
-7
appview/pages/repoinfo/repoinfo.go
···
78
78
func (r RepoInfo) TabMetadata() map[string]any {
79
79
meta := make(map[string]any)
80
80
81
-
if r.Stats.PullCount.Open > 0 {
82
-
meta["pulls"] = r.Stats.PullCount.Open
83
-
}
84
-
85
-
if r.Stats.IssueCount.Open > 0 {
86
-
meta["issues"] = r.Stats.IssueCount.Open
87
-
}
81
+
meta["pulls"] = r.Stats.PullCount.Open
82
+
meta["issues"] = r.Stats.IssueCount.Open
88
83
89
84
// more stuff?
90
85
+24
-4
appview/pages/templates/errors/404.html
+24
-4
appview/pages/templates/errors/404.html
···
1
1
{{ define "title" }}404 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<h1>404 — nothing like that here!</h1>
5
-
<p>
6
-
It seems we couldn't find what you were looking for. Sorry about that!
7
-
</p>
4
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
+
<div class="mb-6">
7
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
8
+
{{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }}
9
+
</div>
10
+
</div>
11
+
12
+
<div class="space-y-4">
13
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
+
404 — page not found
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-300">
17
+
The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
18
+
</p>
19
+
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
+
<a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
21
+
{{ i "arrow-left" "w-4 h-4" }}
22
+
go back
23
+
</a>
24
+
</div>
25
+
</div>
26
+
</div>
27
+
</div>
8
28
{{ end }}
+36
-3
appview/pages/templates/errors/500.html
+36
-3
appview/pages/templates/errors/500.html
···
1
1
{{ define "title" }}500 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<h1>500 — something broke!</h1>
5
-
<p>We're working on getting service back up. Hang tight!</p>
6
-
{{ end }}
4
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
+
<div class="mb-6">
7
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
+
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
+
</div>
10
+
</div>
11
+
12
+
<div class="space-y-4">
13
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
+
500 — internal server error
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-300">
17
+
Something went wrong on our end. We've been notified and are working to fix the issue.
18
+
</p>
19
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
+
<div class="flex items-center gap-2">
21
+
{{ i "info" "w-4 h-4" }}
22
+
<span class="font-medium">we're on it!</span>
23
+
</div>
24
+
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
+
</div>
26
+
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
+
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
28
+
{{ i "refresh-cw" "w-4 h-4" }}
29
+
try again
30
+
</button>
31
+
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
32
+
{{ i "home" "w-4 h-4" }}
33
+
back to home
34
+
</a>
35
+
</div>
36
+
</div>
37
+
</div>
38
+
</div>
39
+
{{ end }}
+28
-5
appview/pages/templates/errors/503.html
+28
-5
appview/pages/templates/errors/503.html
···
1
1
{{ define "title" }}503 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<h1>503 — unable to reach knot</h1>
5
-
<p>
6
-
We were unable to reach the knot hosting this repository. Try again
7
-
later.
8
-
</p>
4
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
+
<div class="mb-6">
7
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
8
+
{{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }}
9
+
</div>
10
+
</div>
11
+
12
+
<div class="space-y-4">
13
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
+
503 — service unavailable
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-300">
17
+
We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
18
+
</p>
19
+
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
+
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
21
+
{{ i "refresh-cw" "w-4 h-4" }}
22
+
try again
23
+
</button>
24
+
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
25
+
{{ i "arrow-left" "w-4 h-4" }}
26
+
back to timeline
27
+
</a>
28
+
</div>
29
+
</div>
30
+
</div>
31
+
</div>
9
32
{{ end }}
+28
appview/pages/templates/errors/knot404.html
+28
appview/pages/templates/errors/knot404.html
···
1
+
{{ define "title" }}404 · tangled{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
+
<div class="mb-6">
7
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
8
+
{{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }}
9
+
</div>
10
+
</div>
11
+
12
+
<div class="space-y-4">
13
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
+
404 — repository not found
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-300">
17
+
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
18
+
</p>
19
+
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
+
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
21
+
{{ i "arrow-left" "w-4 h-4" }}
22
+
back to timeline
23
+
</a>
24
+
</div>
25
+
</div>
26
+
</div>
27
+
</div>
28
+
{{ end }}
+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 }}
+96
-32
appview/pages/templates/knots/dashboard.html
+96
-32
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
1
+
{{ define "title" }}{{ .Registration.Domain }} · knots{{ end }}
2
2
3
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>
4
+
<div class="px-6 py-4">
5
+
<div class="flex justify-between items-center">
6
+
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
7
+
<div id="right-side" class="flex gap-2">
8
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
+
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
10
+
{{ if .Registration.IsRegistered }}
11
+
<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>
12
+
{{ if $isOwner }}
18
13
{{ 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
14
{{ end }}
22
-
</div>
15
+
{{ else if .Registration.IsReadOnly }}
16
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
17
+
{{ i "shield-alert" "w-4 h-4" }} read-only
18
+
</span>
19
+
{{ if $isOwner }}
20
+
{{ block "retryButton" .Registration }} {{ end }}
21
+
{{ end }}
22
+
{{ else }}
23
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
24
+
{{ if $isOwner }}
25
+
{{ block "retryButton" .Registration }} {{ end }}
26
+
{{ end }}
27
+
{{ end }}
28
+
29
+
{{ if $isOwner }}
30
+
{{ block "deleteButton" .Registration }} {{ end }}
31
+
{{ end }}
23
32
</div>
24
-
<div id="operation-error" class="dark:text-red-400"></div>
25
33
</div>
34
+
<div id="operation-error" class="dark:text-red-400"></div>
35
+
</div>
26
36
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 }}
37
+
{{ if .Members }}
38
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
39
+
<div class="flex flex-col gap-2">
40
+
{{ block "member" . }} {{ end }}
41
+
</div>
42
+
</section>
43
+
{{ end }}
34
44
{{ end }}
35
45
36
-
{{ define "knotMember" }}
46
+
47
+
{{ define "member" }}
37
48
{{ range .Members }}
38
49
<div>
39
50
<div class="flex justify-between items-center">
40
51
<div class="flex items-center gap-2">
41
-
{{ i "user" "size-4" }}
42
-
{{ $user := index $.DidHandleMap . }}
43
-
<a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a>
52
+
{{ template "user/fragments/picHandleLink" . }}
53
+
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
44
54
</div>
55
+
{{ if ne $.LoggedInUser.Did . }}
56
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
57
+
{{ end }}
45
58
</div>
46
59
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
47
60
{{ $repos := index $.Repos . }}
48
61
{{ range $repos }}
49
62
<div class="flex gap-2 items-center">
50
63
{{ i "book-marked" "size-4" }}
51
-
<a href="/{{ .Did }}/{{ .Name }}">
64
+
<a href="/{{ resolve .Did }}/{{ .Name }}">
52
65
{{ .Name }}
53
66
</a>
54
67
</div>
55
68
{{ else }}
56
69
<div class="text-gray-500 dark:text-gray-400">
57
-
No repositories created yet.
70
+
No repositories configured yet.
58
71
</div>
59
72
{{ end }}
60
73
</div>
61
74
</div>
62
75
{{ end }}
63
76
{{ end }}
77
+
78
+
{{ define "deleteButton" }}
79
+
<button
80
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
81
+
title="Delete knot"
82
+
hx-delete="/knots/{{ .Domain }}"
83
+
hx-swap="outerHTML"
84
+
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
85
+
hx-headers='{"shouldRedirect": "true"}'
86
+
>
87
+
{{ i "trash-2" "w-5 h-5" }}
88
+
<span class="hidden md:inline">delete</span>
89
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
90
+
</button>
91
+
{{ end }}
92
+
93
+
94
+
{{ define "retryButton" }}
95
+
<button
96
+
class="btn gap-2 group"
97
+
title="Retry knot verification"
98
+
hx-post="/knots/{{ .Domain }}/retry"
99
+
hx-swap="none"
100
+
hx-headers='{"shouldRefresh": "true"}'
101
+
>
102
+
{{ i "rotate-ccw" "w-5 h-5" }}
103
+
<span class="hidden md:inline">retry</span>
104
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
+
</button>
106
+
{{ end }}
107
+
108
+
109
+
{{ define "removeMemberButton" }}
110
+
{{ $root := index . 0 }}
111
+
{{ $member := index . 1 }}
112
+
{{ $memberHandle := resolve $member }}
113
+
<button
114
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
115
+
title="Remove member"
116
+
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
117
+
hx-swap="none"
118
+
hx-vals='{"member": "{{$member}}" }'
119
+
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
120
+
>
121
+
{{ i "user-minus" "w-4 h-4" }}
122
+
remove
123
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
124
+
</button>
125
+
{{ end }}
126
+
127
+
+6
-7
appview/pages/templates/knots/fragments/addMemberModal.html
+6
-7
appview/pages/templates/knots/fragments/addMemberModal.html
···
1
1
{{ define "knots/fragments/addMemberModal" }}
2
2
<button
3
3
class="btn gap-2 group"
4
-
title="Add member to this spindle"
4
+
title="Add member to this knot"
5
5
popovertarget="add-member-{{ .Id }}"
6
6
popovertargetaction="toggle"
7
7
>
···
20
20
21
21
{{ define "addKnotMemberPopover" }}
22
22
<form
23
-
hx-put="/knots/{{ .Domain }}/member"
23
+
hx-post="/knots/{{ .Domain }}/add"
24
24
hx-indicator="#spinner"
25
25
hx-swap="none"
26
26
class="flex flex-col gap-2"
···
28
28
<label for="member-did-{{ .Id }}" class="uppercase p-0">
29
29
ADD MEMBER
30
30
</label>
31
-
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
32
<input
33
33
type="text"
34
34
id="member-did-{{ .Id }}"
35
-
name="subject"
35
+
name="member"
36
36
required
37
37
placeholder="@foo.bsky.social"
38
38
/>
39
39
<div class="flex gap-2 pt-2">
40
-
<button
40
+
<button
41
41
type="button"
42
42
popovertarget="add-member-{{ .Id }}"
43
43
popovertargetaction="hide"
···
54
54
</div>
55
55
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
56
</form>
57
-
{{ end }}
58
-
57
+
{{ end }}
+57
-25
appview/pages/templates/knots/fragments/knotListing.html
+57
-25
appview/pages/templates/knots/fragments/knotListing.html
···
1
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 }}
2
+
<div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
+
{{ block "knotLeftSide" . }} {{ end }}
4
+
{{ block "knotRightSide" . }} {{ end }}
8
5
</div>
9
6
{{ end }}
10
7
11
-
{{ define "listLeftSide" }}
8
+
{{ define "knotLeftSide" }}
9
+
{{ if .Registered }}
10
+
<a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
+
{{ i "hard-drive" "w-4 h-4" }}
12
+
<span class="hover:underline">
13
+
{{ .Domain }}
14
+
</span>
15
+
<span class="text-gray-500">
16
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
17
+
</span>
18
+
</a>
19
+
{{ else }}
12
20
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
13
21
{{ 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 }}
22
+
{{ .Domain }}
21
23
<span class="text-gray-500">
22
24
{{ template "repo/fragments/shortTimeAgo" .Created }}
23
25
</span>
24
26
</div>
27
+
{{ end }}
25
28
{{ end }}
26
29
27
-
{{ define "listRightSide" }}
30
+
{{ define "knotRightSide" }}
28
31
<div id="right-side" class="flex gap-2">
29
32
{{ $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>
33
+
{{ if .IsRegistered }}
34
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">
35
+
{{ i "shield-check" "w-4 h-4" }} verified
36
+
</span>
32
37
{{ template "knots/fragments/addMemberModal" . }}
38
+
{{ block "knotDeleteButton" . }} {{ end }}
39
+
{{ else if .IsReadOnly }}
40
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
41
+
{{ i "shield-alert" "w-4 h-4" }} read-only
42
+
</span>
43
+
{{ block "knotRetryButton" . }} {{ end }}
44
+
{{ block "knotDeleteButton" . }} {{ end }}
33
45
{{ 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 }}
46
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">
47
+
{{ i "shield-off" "w-4 h-4" }} unverified
48
+
</span>
49
+
{{ block "knotRetryButton" . }} {{ end }}
50
+
{{ block "knotDeleteButton" . }} {{ end }}
36
51
{{ end }}
37
52
</div>
38
53
{{ end }}
39
54
40
-
{{ define "initializeButton" }}
55
+
{{ define "knotDeleteButton" }}
56
+
<button
57
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
58
+
title="Delete knot"
59
+
hx-delete="/knots/{{ .Domain }}"
60
+
hx-swap="outerHTML"
61
+
hx-target="#knot-{{.Id}}"
62
+
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
63
+
>
64
+
{{ i "trash-2" "w-5 h-5" }}
65
+
<span class="hidden md:inline">delete</span>
66
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
+
</button>
68
+
{{ end }}
69
+
70
+
71
+
{{ define "knotRetryButton" }}
41
72
<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"
73
+
class="btn gap-2 group"
74
+
title="Retry knot verification"
75
+
hx-post="/knots/{{ .Domain }}/retry"
44
76
hx-swap="none"
77
+
hx-target="#knot-{{.Id}}"
45
78
>
46
-
{{ i "square-play" "w-5 h-5" }}
47
-
<span class="hidden md:inline">initialize</span>
79
+
{{ i "rotate-ccw" "w-5 h-5" }}
80
+
<span class="hidden md:inline">retry</span>
48
81
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
82
</button>
50
83
{{ 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 }}
+23
-8
appview/pages/templates/knots/index.html
+23
-8
appview/pages/templates/knots/index.html
···
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
10
{{ block "about" . }} {{ end }}
11
-
{{ template "knots/fragments/knotListingFull" . }}
11
+
{{ block "list" . }} {{ end }}
12
12
{{ block "register" . }} {{ end }}
13
13
</div>
14
14
</section>
···
27
27
</section>
28
28
{{ end }}
29
29
30
+
{{ define "list" }}
31
+
<section class="rounded w-full flex flex-col gap-2">
32
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2>
33
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
34
+
{{ range $registration := .Registrations }}
35
+
{{ template "knots/fragments/knotListing" . }}
36
+
{{ else }}
37
+
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
38
+
no knots registered yet
39
+
</div>
40
+
{{ end }}
41
+
</div>
42
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
43
+
</section>
44
+
{{ end }}
45
+
30
46
{{ define "register" }}
31
-
<section class="rounded max-w-2xl flex flex-col gap-2">
47
+
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
32
48
<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>
49
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
34
50
<form
35
-
hx-post="/knots/key"
36
-
class="space-y-4"
51
+
hx-post="/knots/register"
52
+
class="max-w-2xl mb-2 space-y-4"
37
53
hx-indicator="#register-button"
38
54
hx-swap="none"
39
55
>
···
53
69
>
54
70
<span class="inline-flex items-center gap-2">
55
71
{{ i "plus" "w-4 h-4" }}
56
-
generate
72
+
register
57
73
</span>
58
74
<span class="pl-2 hidden group-[.htmx-request]:inline">
59
75
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
···
61
77
</button>
62
78
</div>
63
79
64
-
<div id="registration-error" class="error dark:text-red-400"></div>
80
+
<div id="register-error" class="error dark:text-red-400"></div>
65
81
</form>
66
82
67
-
<div id="secret"></div>
68
83
</section>
69
84
{{ end }}
+2
-14
appview/pages/templates/layouts/base.html
+2
-14
appview/pages/templates/layouts/base.html
···
17
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
18
{{ block "topbarLayout" . }}
19
19
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
20
-
{{ template "layouts/topbar" . }}
20
+
{{ template "layouts/fragments/topbar" . }}
21
21
</header>
22
22
{{ end }}
23
23
24
24
{{ block "mainLayout" . }}
25
25
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
26
26
{{ block "contentLayout" . }}
27
-
<div class="col-span-1 md:col-span-2">
28
-
{{ block "contentLeft" . }} {{ end }}
29
-
</div>
30
27
<main class="col-span-1 md:col-span-8">
31
28
{{ block "content" . }}{{ end }}
32
29
</main>
33
-
<div class="col-span-1 md:col-span-2">
34
-
{{ block "contentRight" . }} {{ end }}
35
-
</div>
36
30
{{ end }}
37
31
38
32
{{ block "contentAfterLayout" . }}
39
-
<div class="col-span-1 md:col-span-2">
40
-
{{ block "contentAfterLeft" . }} {{ end }}
41
-
</div>
42
33
<main class="col-span-1 md:col-span-8">
43
34
{{ block "contentAfter" . }}{{ end }}
44
35
</main>
45
-
<div class="col-span-1 md:col-span-2">
46
-
{{ block "contentAfterRight" . }} {{ end }}
47
-
</div>
48
36
{{ end }}
49
37
</div>
50
38
{{ end }}
51
39
52
40
{{ block "footerLayout" . }}
53
41
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
54
-
{{ template "layouts/footer" . }}
42
+
{{ template "layouts/fragments/footer" . }}
55
43
</footer>
56
44
{{ end }}
57
45
</body>
+87
appview/pages/templates/layouts/fragments/topbar.html
+87
appview/pages/templates/layouts/fragments/topbar.html
···
1
+
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
+
<div class="flex justify-between p-0 items-center">
4
+
<div id="left-items">
5
+
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
6
+
tangled<sub>alpha</sub>
7
+
</a>
8
+
</div>
9
+
10
+
<div id="right-items" class="flex items-center gap-2">
11
+
{{ with .LoggedInUser }}
12
+
{{ block "newButton" . }} {{ end }}
13
+
{{ block "dropDown" . }} {{ end }}
14
+
{{ else }}
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>
20
+
{{ end }}
21
+
</div>
22
+
</div>
23
+
</nav>
24
+
{{ if .LoggedInUser }}
25
+
<div id="upgrade-banner"
26
+
hx-get="/knots/upgradeBanner"
27
+
hx-trigger="load"
28
+
hx-swap="innerHTML">
29
+
</div>
30
+
{{ end }}
31
+
{{ end }}
32
+
33
+
{{ define "newButton" }}
34
+
<details class="relative inline-block text-left nav-dropdown">
35
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
36
+
{{ i "plus" "w-4 h-4" }} new
37
+
</summary>
38
+
<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">
39
+
<a href="/repo/new" class="flex items-center gap-2">
40
+
{{ i "book-plus" "w-4 h-4" }}
41
+
new repository
42
+
</a>
43
+
<a href="/strings/new" class="flex items-center gap-2">
44
+
{{ i "line-squiggle" "w-4 h-4" }}
45
+
new string
46
+
</a>
47
+
</div>
48
+
</details>
49
+
{{ end }}
50
+
51
+
{{ define "dropDown" }}
52
+
<details class="relative inline-block text-left nav-dropdown">
53
+
<summary
54
+
class="cursor-pointer list-none flex items-center"
55
+
>
56
+
{{ $user := didOrHandle .Did .Handle }}
57
+
{{ template "user/fragments/picHandle" $user }}
58
+
</summary>
59
+
<div
60
+
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"
61
+
>
62
+
<a href="/{{ $user }}">profile</a>
63
+
<a href="/{{ $user }}?tab=repos">repositories</a>
64
+
<a href="/{{ $user }}?tab=strings">strings</a>
65
+
<a href="/knots">knots</a>
66
+
<a href="/spindles">spindles</a>
67
+
<a href="/settings">settings</a>
68
+
<a href="#"
69
+
hx-post="/logout"
70
+
hx-swap="none"
71
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
72
+
logout
73
+
</a>
74
+
</div>
75
+
</details>
76
+
77
+
<script>
78
+
document.addEventListener('click', function(event) {
79
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
80
+
dropdowns.forEach(function(dropdown) {
81
+
if (!dropdown.contains(event.target)) {
82
+
dropdown.removeAttribute('open');
83
+
}
84
+
});
85
+
});
86
+
</script>
87
+
{{ end }}
+104
appview/pages/templates/layouts/profilebase.html
+104
appview/pages/templates/layouts/profilebase.html
···
1
+
{{ define "title" }}{{ 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 }}?tab={{ .Active }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
{{ template "profileTabs" . }}
12
+
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
13
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
14
+
<div class="md:col-span-3 order-1 md:order-1">
15
+
<div class="flex flex-col gap-4">
16
+
{{ template "user/fragments/profileCard" .Card }}
17
+
{{ block "punchcard" .Card.Punchcard }} {{ end }}
18
+
</div>
19
+
</div>
20
+
{{ block "profileContent" . }} {{ end }}
21
+
</div>
22
+
</section>
23
+
{{ end }}
24
+
25
+
{{ define "profileTabs" }}
26
+
<nav class="w-full pl-4 overflow-x-auto overflow-y-hidden">
27
+
<div class="flex z-60">
28
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
29
+
{{ $tabs := .Card.GetTabs }}
30
+
{{ $tabmeta := dict "x" "y" }}
31
+
{{ range $item := $tabs }}
32
+
{{ $key := index $item 0 }}
33
+
{{ $value := index $item 1 }}
34
+
{{ $icon := index $item 2 }}
35
+
{{ $meta := index $item 3 }}
36
+
<a
37
+
href="?tab={{ $value }}"
38
+
class="relative -mr-px group no-underline hover:no-underline"
39
+
hx-boost="true">
40
+
<div
41
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
42
+
{{ if eq $.Active $key }}
43
+
{{ $activeTabStyles }}
44
+
{{ else }}
45
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
46
+
{{ end }}
47
+
">
48
+
<span class="flex items-center justify-center">
49
+
{{ i $icon "w-4 h-4 mr-2" }}
50
+
{{ $key }}
51
+
{{ if $meta }}
52
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
53
+
{{ end }}
54
+
</span>
55
+
</div>
56
+
</a>
57
+
{{ end }}
58
+
</div>
59
+
</nav>
60
+
{{ end }}
61
+
62
+
{{ define "punchcard" }}
63
+
{{ $now := now }}
64
+
<div>
65
+
<p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white">
66
+
PUNCHCARD
67
+
<span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 ">
68
+
{{ .Total | int64 | commaFmt }} commits
69
+
</span>
70
+
</p>
71
+
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full">
72
+
{{ range .Punches }}
73
+
{{ $count := .Count }}
74
+
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
75
+
{{ if lt $count 1 }}
76
+
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
77
+
{{ else if lt $count 2 }}
78
+
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
79
+
{{ else if lt $count 4 }}
80
+
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
81
+
{{ else if lt $count 8 }}
82
+
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
83
+
{{ else }}
84
+
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
85
+
{{ end }}
86
+
87
+
{{ if .Date.After $now }}
88
+
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
89
+
{{ end }}
90
+
<div class="w-full h-full flex justify-center items-center">
91
+
<div
92
+
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
93
+
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
94
+
</div>
95
+
</div>
96
+
{{ end }}
97
+
</div>
98
+
</div>
99
+
{{ end }}
100
+
101
+
{{ define "layouts/profilebase" }}
102
+
{{ template "layouts/base" . }}
103
+
{{ end }}
104
+
+18
-27
appview/pages/templates/layouts/repobase.html
+18
-27
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>
···
20
20
</div>
21
21
22
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>
23
29
{{ template "repo/fragments/repoStar" .RepoInfo }}
24
-
{{ if .RepoInfo.DisableFork }}
25
-
<button
26
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
27
-
disabled
28
-
title="Empty repositories cannot be forked"
29
-
>
30
-
{{ i "git-fork" "w-4 h-4" }}
31
-
fork
32
-
</button>
33
-
{{ else }}
34
-
<a
35
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
36
-
hx-boost="true"
37
-
href="/{{ .RepoInfo.FullName }}/fork"
38
-
>
39
-
{{ i "git-fork" "w-4 h-4" }}
40
-
fork
41
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
42
-
</a>
43
-
{{ end }}
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>
44
39
</div>
45
40
</div>
46
41
{{ template "repo/fragments/repoDescription" . }}
···
76
71
<span class="flex items-center justify-center">
77
72
{{ i $icon "w-4 h-4 mr-2" }}
78
73
{{ $key }}
79
-
{{ if not (isNil $meta) }}
80
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
74
+
{{ if $meta }}
75
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
81
76
{{ end }}
82
77
</span>
83
78
</div>
···
93
88
{{ block "repoAfter" . }}{{ end }}
94
89
</section>
95
90
{{ end }}
96
-
97
-
{{ define "layouts/repobase" }}
98
-
{{ template "layouts/base" . }}
99
-
{{ end }}
-69
appview/pages/templates/layouts/topbar.html
-69
appview/pages/templates/layouts/topbar.html
···
1
-
{{ define "layouts/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
-
<div class="flex justify-between p-0 items-center">
4
-
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
-
tangled<sub>alpha</sub>
7
-
</a>
8
-
</div>
9
-
10
-
<div id="right-items" class="flex items-center gap-2">
11
-
{{ with .LoggedInUser }}
12
-
{{ block "newButton" . }} {{ end }}
13
-
{{ block "dropDown" . }} {{ end }}
14
-
{{ else }}
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>
20
-
{{ end }}
21
-
</div>
22
-
</div>
23
-
</nav>
24
-
{{ end }}
25
-
26
-
{{ define "newButton" }}
27
-
<details class="relative inline-block text-left">
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
-
44
-
{{ define "dropDown" }}
45
-
<details class="relative inline-block text-left">
46
-
<summary
47
-
class="cursor-pointer list-none flex items-center"
48
-
>
49
-
{{ $user := didOrHandle .Did .Handle }}
50
-
{{ template "user/fragments/picHandle" $user }}
51
-
</summary>
52
-
<div
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"
54
-
>
55
-
<a href="/{{ $user }}">profile</a>
56
-
<a href="/{{ $user }}?tab=repos">repositories</a>
57
-
<a href="/strings/{{ $user }}">strings</a>
58
-
<a href="/knots">knots</a>
59
-
<a href="/spindles">spindles</a>
60
-
<a href="/settings">settings</a>
61
-
<a href="#"
62
-
hx-post="/logout"
63
-
hx-swap="none"
64
-
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
65
-
logout
66
-
</a>
67
-
</div>
68
-
</details>
69
-
{{ end }}
+3
-3
appview/pages/templates/repo/commit.html
+3
-3
appview/pages/templates/repo/commit.html
···
81
81
82
82
{{ define "topbarLayout" }}
83
83
<header class="px-1 col-span-full" style="z-index: 20;">
84
-
{{ template "layouts/topbar" . }}
84
+
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
···
106
106
107
107
{{ define "footerLayout" }}
108
108
<footer class="px-1 col-span-full mt-12">
109
-
{{ template "layouts/footer" . }}
109
+
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
112
112
···
118
118
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
119
119
{{ template "repo/fragments/diffOpts" .DiffOpts }}
120
120
</div>
121
-
<div class="sticky top-0 flex-grow max-h-screen">
121
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
122
122
{{ template "repo/fragments/diffChangedFiles" .Diff }}
123
123
</div>
124
124
{{end}}
+3
-3
appview/pages/templates/repo/compare/compare.html
+3
-3
appview/pages/templates/repo/compare/compare.html
···
12
12
13
13
{{ define "topbarLayout" }}
14
14
<header class="px-1 col-span-full" style="z-index: 20;">
15
-
{{ template "layouts/topbar" . }}
15
+
{{ template "layouts/fragments/topbar" . }}
16
16
</header>
17
17
{{ end }}
18
18
···
37
37
38
38
{{ define "footerLayout" }}
39
39
<footer class="px-1 col-span-full mt-12">
40
-
{{ template "layouts/footer" . }}
40
+
{{ template "layouts/fragments/footer" . }}
41
41
</footer>
42
42
{{ end }}
43
43
···
49
49
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
50
50
{{ template "repo/fragments/diffOpts" .DiffOpts }}
51
51
</div>
52
-
<div class="sticky top-0 flex-grow max-h-screen">
52
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
53
53
{{ template "repo/fragments/diffChangedFiles" .Diff }}
54
54
</div>
55
55
{{end}}
+5
-7
appview/pages/templates/repo/empty.html
+5
-7
appview/pages/templates/repo/empty.html
···
32
32
<div class="py-6 w-fit flex flex-col gap-4">
33
33
<p>This is an empty repository. To get started:</p>
34
34
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
-
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
-
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
37
-
<p><span class="{{$bullet}}">3</span>Push!</p>
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>
38
40
</div>
39
41
</div>
40
42
{{ else }}
···
42
44
{{ end }}
43
45
</main>
44
46
{{ end }}
45
-
46
-
{{ define "repoAfter" }}
47
-
{{ template "repo/fragments/cloneInstructions" . }}
48
-
{{ end }}
+8
-2
appview/pages/templates/repo/fork.html
+8
-2
appview/pages/templates/repo/fork.html
···
5
5
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
8
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
9
<fieldset class="space-y-3">
10
10
<legend class="dark:text-white">Select a knot to fork into</legend>
11
11
<div class="space-y-2">
···
30
30
</fieldset>
31
31
32
32
<div class="space-y-2">
33
-
<button type="submit" class="btn">fork repo</button>
33
+
<button type="submit" class="btn-create flex items-center gap-2">
34
+
{{ i "git-fork" "w-4 h-4" }}
35
+
fork repo
36
+
<span id="spinner" class="group">
37
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
+
</span>
39
+
</button>
34
40
<div id="repo" class="error"></div>
35
41
</div>
36
42
</form>
+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 }}
+29
-83
appview/pages/templates/repo/fragments/diff.html
+29
-83
appview/pages/templates/repo/fragments/diff.html
···
13
13
<div class="flex flex-col gap-4">
14
14
{{ range $idx, $hunk := $diff }}
15
15
{{ with $hunk }}
16
-
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
17
-
<div id="file-{{ .Name.New }}">
18
-
<div id="diff-file">
19
-
<details open>
20
-
<summary class="list-none cursor-pointer sticky top-0">
21
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
22
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
23
-
<div class="flex gap-1 items-center">
24
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
25
-
{{ if .IsNew }}
26
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
27
-
{{ else if .IsDelete }}
28
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
29
-
{{ else if .IsCopy }}
30
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
31
-
{{ else if .IsRename }}
32
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
33
-
{{ else }}
34
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
35
-
{{ end }}
36
-
37
-
{{ template "repo/fragments/diffStatPill" .Stats }}
38
-
</div>
39
-
40
-
<div class="flex gap-2 items-center overflow-x-auto">
41
-
{{ if .IsDelete }}
42
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
43
-
{{ .Name.Old }}
44
-
</a>
45
-
{{ else if (or .IsCopy .IsRename) }}
46
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
47
-
{{ .Name.Old }}
48
-
</a>
49
-
{{ i "arrow-right" "w-4 h-4" }}
50
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
51
-
{{ .Name.New }}
52
-
</a>
53
-
{{ else }}
54
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
55
-
{{ .Name.New }}
56
-
</a>
57
-
{{ end }}
58
-
</div>
59
-
</div>
60
-
61
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
62
-
<div id="right-side-items" class="p-2 flex items-center">
63
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
64
-
{{ if gt $idx 0 }}
65
-
{{ $prev := index $diff (sub $idx 1) }}
66
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
67
-
{{ end }}
68
-
69
-
{{ if lt $idx $last }}
70
-
{{ $next := index $diff (add $idx 1) }}
71
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
72
-
{{ end }}
73
-
</div>
74
-
75
-
</div>
76
-
</summary>
16
+
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
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
+
<span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span>
21
+
<span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span>
22
+
{{ template "repo/fragments/diffStatPill" .Stats }}
77
23
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 -}}
24
+
<div class="flex gap-2 items-center overflow-x-auto">
25
+
{{ if .IsDelete }}
26
+
{{ .Name.Old }}
27
+
{{ else if (or .IsCopy .IsRename) }}
28
+
{{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }}
94
29
{{ else }}
95
-
{{- template "repo/fragments/unifiedDiff" . -}}
30
+
{{ .Name.New }}
96
31
{{ end }}
97
-
{{- end -}}
32
+
</div>
98
33
</div>
34
+
</div>
35
+
</summary>
99
36
100
-
</details>
101
-
37
+
<div class="transition-all duration-700 ease-in-out">
38
+
{{ if .IsBinary }}
39
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
40
+
This is a binary file and will not be displayed.
41
+
</p>
42
+
{{ else }}
43
+
{{ if $isSplit }}
44
+
{{- template "repo/fragments/splitDiff" .Split -}}
45
+
{{ else }}
46
+
{{- template "repo/fragments/unifiedDiff" . -}}
47
+
{{ end }}
48
+
{{- end -}}
102
49
</div>
103
-
</div>
104
-
</section>
50
+
</details>
105
51
{{ end }}
106
52
{{ end }}
107
53
</div>
+4
appview/pages/templates/repo/fragments/duration.html
+4
appview/pages/templates/repo/fragments/duration.html
+4
-4
appview/pages/templates/repo/fragments/fileTree.html
+4
-4
appview/pages/templates/repo/fragments/fileTree.html
···
3
3
<details open>
4
4
<summary class="cursor-pointer list-none pt-1">
5
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>
6
+
{{ i "folder" "flex-shrink-0 size-4 fill-current" }}
7
+
<span class="filename truncate text-black dark:text-white">{{ .Name }}</span>
8
8
</span>
9
9
</summary>
10
10
<div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700">
···
15
15
</details>
16
16
{{ else if .Name }}
17
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>
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
20
</div>
21
21
{{ else }}
22
22
{{ range $child := .Children }}
+44
-69
appview/pages/templates/repo/fragments/interdiff.html
+44
-69
appview/pages/templates/repo/fragments/interdiff.html
···
10
10
<div class="flex flex-col gap-4">
11
11
{{ range $idx, $hunk := $diff }}
12
12
{{ with $hunk }}
13
-
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
14
-
<div id="file-{{ .Name }}">
15
-
<div id="diff-file">
16
-
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
17
-
<summary class="list-none cursor-pointer sticky top-0">
18
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
19
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
20
-
<div class="flex gap-1 items-center" style="direction: ltr;">
21
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
22
-
{{ if .Status.IsOk }}
23
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
24
-
{{ else if .Status.IsUnchanged }}
25
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
26
-
{{ else if .Status.IsOnlyInOne }}
27
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
28
-
{{ else if .Status.IsOnlyInTwo }}
29
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
30
-
{{ else if .Status.IsRebased }}
31
-
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
32
-
{{ else }}
33
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
34
-
{{ end }}
35
-
</div>
36
-
37
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
38
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
39
-
{{ .Name }}
40
-
</a>
41
-
</div>
42
-
</div>
43
-
44
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
45
-
<div id="right-side-items" class="p-2 flex items-center">
46
-
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
47
-
{{ if gt $idx 0 }}
48
-
{{ $prev := index $diff (sub $idx 1) }}
49
-
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
50
-
{{ end }}
51
-
52
-
{{ if lt $idx $last }}
53
-
{{ $next := index $diff (add $idx 1) }}
54
-
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
55
-
{{ end }}
56
-
</div>
57
-
13
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
14
+
<summary class="list-none cursor-pointer sticky top-0">
15
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
16
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
17
+
<div class="flex gap-1 items-center" style="direction: ltr;">
18
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
19
+
{{ if .Status.IsOk }}
20
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
21
+
{{ else if .Status.IsUnchanged }}
22
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
23
+
{{ else if .Status.IsOnlyInOne }}
24
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
25
+
{{ else if .Status.IsOnlyInTwo }}
26
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
27
+
{{ else if .Status.IsRebased }}
28
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
29
+
{{ else }}
30
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
31
+
{{ end }}
58
32
</div>
59
-
</summary>
60
33
61
-
<div class="transition-all duration-700 ease-in-out">
62
-
{{ if .Status.IsUnchanged }}
63
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
64
-
This file has not been changed.
65
-
</p>
66
-
{{ else if .Status.IsRebased }}
67
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
68
-
This patch was likely rebased, as context lines do not match.
69
-
</p>
70
-
{{ else if .Status.IsError }}
71
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
72
-
Failed to calculate interdiff for this file.
73
-
</p>
74
-
{{ else }}
75
-
{{ if $isSplit }}
76
-
{{- template "repo/fragments/splitDiff" .Split -}}
77
-
{{ else }}
78
-
{{- template "repo/fragments/unifiedDiff" . -}}
79
-
{{ end }}
80
-
{{- end -}}
34
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div>
81
35
</div>
82
36
83
-
</details>
37
+
</div>
38
+
</summary>
84
39
40
+
<div class="transition-all duration-700 ease-in-out">
41
+
{{ if .Status.IsUnchanged }}
42
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
43
+
This file has not been changed.
44
+
</p>
45
+
{{ else if .Status.IsRebased }}
46
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
47
+
This patch was likely rebased, as context lines do not match.
48
+
</p>
49
+
{{ else if .Status.IsError }}
50
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
51
+
Failed to calculate interdiff for this file.
52
+
</p>
53
+
{{ else }}
54
+
{{ if $isSplit }}
55
+
{{- template "repo/fragments/splitDiff" .Split -}}
56
+
{{ else }}
57
+
{{- template "repo/fragments/unifiedDiff" . -}}
58
+
{{ end }}
59
+
{{- end -}}
85
60
</div>
86
-
</div>
87
-
</section>
61
+
62
+
</details>
88
63
{{ end }}
89
64
{{ end }}
90
65
</div>
+1
-1
appview/pages/templates/repo/fragments/interdiffFiles.html
+1
-1
appview/pages/templates/repo/fragments/interdiffFiles.html
···
1
1
{{ define "repo/fragments/interdiffFiles" }}
2
2
{{ $fileTree := fileTree .AffectedFiles }}
3
-
<section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm">
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
4
<div class="diff-stat">
5
5
<div class="flex gap-2 items-center">
6
6
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
+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 }}
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
-16
appview/pages/templates/repo/fragments/time.html
-16
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
1
{{ define "repo/fragments/time" }}
6
2
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
7
3
{{ 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 }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+91
-109
appview/pages/templates/repo/index.html
+91
-109
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">
85
-
{{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }}
86
-
{{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }}
87
-
{{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }}
88
-
{{ $disabled := "" }}
89
-
{{ $title := "" }}
90
-
{{ if eq .ForkInfo.Status 0 }}
91
-
{{ $disabled = "disabled" }}
92
-
{{ $title = "This branch is not behind the upstream" }}
93
-
{{ else if eq .ForkInfo.Status 2 }}
94
-
{{ $disabled = "disabled" }}
95
-
{{ $title = "This branch has conflicts that must be resolved" }}
96
-
{{ else if eq .ForkInfo.Status 3 }}
97
-
{{ $disabled = "disabled" }}
98
-
{{ $title = "This branch does not exist on the upstream" }}
99
-
{{ end }}
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">
87
+
<a
88
+
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
89
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
90
+
title="Compare branches or tags"
91
+
>
92
+
{{ i "git-compare" "w-4 h-4" }}
93
+
</a>
94
+
</div>
95
+
</div>
100
96
101
-
<button
102
-
id="syncBtn"
103
-
{{ $disabled }}
104
-
{{ if $title }}title="{{ $title }}"{{ end }}
105
-
class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed"
106
-
hx-post="/{{ .RepoInfo.FullName }}/fork/sync"
107
-
hx-trigger="click"
108
-
hx-swap="none"
109
-
>
110
-
{{ if $disabled }}
111
-
{{ i "refresh-cw-off" "w-4 h-4" }}
112
-
{{ else }}
113
-
{{ i "refresh-cw" "w-4 h-4" }}
114
-
{{ end }}
115
-
<span>sync</span>
116
-
</button>
117
-
{{ 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>
97
+
<!-- Clone dropdown in top right -->
98
+
<div class="hidden md:flex items-center ">
99
+
{{ template "repo/fragments/cloneDropdown" . }}
125
100
</div>
126
-
</div>
101
+
</div>
127
102
{{ end }}
128
103
129
104
{{ define "fileTree" }}
···
131
106
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
132
107
133
108
{{ range .Files }}
134
-
<div class="grid grid-cols-2 gap-4 items-center py-1">
135
-
<div class="col-span-1">
109
+
<div class="grid grid-cols-3 gap-4 items-center py-1">
110
+
<div class="col-span-2">
136
111
{{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }}
137
112
{{ $icon := "folder" }}
138
113
{{ $iconStyle := "size-4 fill-current" }}
···
144
119
{{ end }}
145
120
<a href="{{ $link }}" class="{{ $linkstyle }}">
146
121
<div class="flex items-center gap-2">
147
-
{{ i $icon $iconStyle }}{{ .Name }}
122
+
{{ i $icon $iconStyle "flex-shrink-0" }}
123
+
<span class="truncate">{{ .Name }}</span>
148
124
</div>
149
125
</a>
150
126
</div>
151
127
152
-
<div class="text-xs col-span-1 text-right">
128
+
<div class="text-sm col-span-1 text-right">
153
129
{{ with .LastCommit }}
154
130
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
155
131
{{ end }}
···
210
186
</div>
211
187
212
188
<!-- commit info bar -->
213
-
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center">
189
+
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap">
214
190
{{ $verified := $.VerifiedCommits.IsVerified .Hash.String }}
215
191
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
216
192
{{ if $verified }}
···
280
256
</a>
281
257
<div class="flex flex-col gap-1">
282
258
{{ range .BranchesTrunc }}
283
-
<div class="text-base flex items-center justify-between">
284
-
<div class="flex items-center gap-2">
259
+
<div class="text-base flex items-center justify-between overflow-hidden">
260
+
<div class="flex items-center gap-2 min-w-0 flex-1">
285
261
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}"
286
-
class="inline no-underline hover:underline dark:text-white">
262
+
class="inline-block truncate no-underline hover:underline dark:text-white">
287
263
{{ .Reference.Name }}
288
264
</a>
289
265
{{ if .Commit }}
290
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
291
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
266
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
267
+
<span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
292
268
{{ end }}
293
269
{{ if .IsDefault }}
294
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
295
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span>
270
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
271
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span>
296
272
{{ end }}
297
273
</div>
298
274
{{ if ne $.Ref .Reference.Name }}
299
275
<a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}"
300
-
class="text-xs flex gap-2 items-center"
276
+
class="text-xs flex gap-2 items-center shrink-0 ml-2"
301
277
title="Compare branches or tags">
302
278
{{ i "git-compare" "w-3 h-3" }} compare
303
279
</a>
304
-
{{end}}
280
+
{{ end }}
305
281
</div>
306
282
{{ end }}
307
283
</div>
···
347
323
348
324
{{ define "repoAfter" }}
349
325
{{- if or .HTMLReadme .Readme -}}
350
-
<section
351
-
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
352
-
prose dark:prose-invert dark:[&_pre]:bg-gray-900
353
-
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
354
-
dark:[&_pre]:border dark:[&_pre]:border-gray-700
355
-
{{ end }}"
356
-
>
357
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
-
{{- .Readme -}}
359
-
</pre>
360
-
{{- else -}}
361
-
{{ .HTMLReadme }}
362
-
{{- end -}}</article>
363
-
</section>
326
+
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
327
+
{{- if .ReadmeFileName -}}
328
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
329
+
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
330
+
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
331
+
</div>
332
+
{{- end -}}
333
+
<section
334
+
class="p-6 overflow-auto {{ if not .Raw }}
335
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
336
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
337
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
338
+
{{ end }}"
339
+
>
340
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
341
+
{{- .Readme -}}
342
+
</pre>
343
+
{{- else -}}
344
+
{{ .HTMLReadme }}
345
+
{{- end -}}</article>
346
+
</section>
347
+
</div>
364
348
{{- end -}}
365
-
366
-
{{ template "repo/fragments/cloneInstructions" . }}
367
349
{{ end }}
+1
-2
appview/pages/templates/repo/issues/fragments/issueComment.html
+1
-2
appview/pages/templates/repo/issues/fragments/issueComment.html
···
2
2
{{ with .Comment }}
3
3
<div id="comment-container-{{.CommentId}}">
4
4
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
{{ template "user/fragments/picHandleLink" $owner }}
5
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
7
6
8
7
<!-- show user "hats" -->
9
8
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+3
-3
appview/pages/templates/repo/issues/issue.html
+3
-3
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>
···
54
54
"Kind" $kind
55
55
"Count" (index $.Reactions $kind)
56
56
"IsReacted" (index $.UserReacted $kind)
57
-
"ThreadAt" $.Issue.IssueAt)
57
+
"ThreadAt" $.Issue.AtUri)
58
58
}}
59
59
{{ end }}
60
60
</div>
···
70
70
{{ if gt $index 0 }}
71
71
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
72
72
{{ end }}
73
-
{{ 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" .)}}
74
74
</div>
75
75
{{ end }}
76
76
</section>
+2
-3
appview/pages/templates/repo/issues/issues.html
+2
-3
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/picHandleLink" $owner }}
68
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
70
69
</span>
71
70
72
71
<span class="before:content-['ยท']">
+1
-1
appview/pages/templates/repo/new.html
+1
-1
appview/pages/templates/repo/new.html
···
63
63
<button type="submit" class="btn-create flex items-center gap-2">
64
64
{{ i "book-plus" "w-4 h-4" }}
65
65
create repo
66
-
<span id="create-pull-spinner" class="group">
66
+
<span id="spinner" class="group">
67
67
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
68
</span>
69
69
</button>
+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 }}
+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" }}
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
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
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
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">
25
29
{{ range $name, $all := .Statuses }}
26
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">
27
31
<div
28
-
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 }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
+2
-2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+2
-2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
19
19
>
20
20
<option disabled selected>select a fork</option>
21
21
{{ range .Forks }}
22
-
<option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
23
-
{{ .Name }}
22
+
<option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
23
+
{{ .Did | resolve }}/{{ .Name }}
24
24
</option>
25
25
{{ end }}
26
26
</select>
+3
-3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+3
-3
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>
···
28
28
</div>
29
29
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
30
30
opened by
31
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
-
{{ template "user/fragments/picHandleLink" $owner }}
31
+
{{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
33
32
<span class="select-none before:content-['\00B7']"></span>
34
33
{{ template "repo/fragments/time" .Pull.Created }}
35
34
···
45
44
<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">
46
45
{{ if .Pull.IsForkBased }}
47
46
{{ if .Pull.PullSource.Repo }}
47
+
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
48
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
49
{{- else -}}
50
50
<span class="italic">[deleted fork]</span>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
52
52
</div>
53
53
{{ end }}
54
54
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
55
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
55
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
56
56
</div>
57
57
</div>
58
58
</a>
+2
-2
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+2
-2
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
1
-
{{ define "repo/pulls/fragments/summarizedHeader" }}
1
+
{{ define "repo/pulls/fragments/summarizedPullHeader" }}
2
2
{{ $pull := index . 0 }}
3
3
{{ $pipeline := index . 1 }}
4
4
{{ with $pull }}
···
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
+3
-3
appview/pages/templates/repo/pulls/interdiff.html
+3
-3
appview/pages/templates/repo/pulls/interdiff.html
···
30
30
31
31
{{ define "topbarLayout" }}
32
32
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/topbar" . }}
33
+
{{ template "layouts/fragments/topbar" . }}
34
34
</header>
35
35
{{ end }}
36
36
···
55
55
56
56
{{ define "footerLayout" }}
57
57
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/footer" . }}
58
+
{{ template "layouts/fragments/footer" . }}
59
59
</footer>
60
60
{{ end }}
61
61
···
68
68
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
69
69
{{ template "repo/fragments/diffOpts" .DiffOpts }}
70
70
</div>
71
-
<div class="sticky top-0 flex-grow max-h-screen">
71
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
72
72
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
73
73
</div>
74
74
{{end}}
+3
-3
appview/pages/templates/repo/pulls/patch.html
+3
-3
appview/pages/templates/repo/pulls/patch.html
···
36
36
37
37
{{ define "topbarLayout" }}
38
38
<header class="px-1 col-span-full" style="z-index: 20;">
39
-
{{ template "layouts/topbar" . }}
39
+
{{ template "layouts/fragments/topbar" . }}
40
40
</header>
41
41
{{ end }}
42
42
···
61
61
62
62
{{ define "footerLayout" }}
63
63
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/footer" . }}
64
+
{{ template "layouts/fragments/footer" . }}
65
65
</footer>
66
66
{{ end }}
67
67
···
73
73
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
74
74
{{ template "repo/fragments/diffOpts" .DiffOpts }}
75
75
</div>
76
-
<div class="sticky top-0 flex-grow max-h-screen">
76
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
77
77
{{ template "repo/fragments/diffChangedFiles" .Diff }}
78
78
</div>
79
79
{{end}}
+4
-5
appview/pages/templates/repo/pulls/pull.html
+4
-5
appview/pages/templates/repo/pulls/pull.html
···
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
49
<span class="gap-1 flex items-center">
50
-
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
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 {{ template "user/fragments/picHandleLink" $owner }}
56
+
by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }}
57
57
<span class="select-none before:content-['\00B7']"></span>
58
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>
···
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"
···
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
153
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
154
-
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
155
-
{{ template "user/fragments/picHandleLink" $owner }}
154
+
{{ template "user/fragments/picHandleLink" $c.OwnerDid }}
156
155
<span class="before:content-['ยท']"></span>
157
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>
+3
-4
appview/pages/templates/repo/pulls/pulls.html
+3
-4
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
57
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
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/picHandleLink" $owner }}
78
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
80
79
</span>
81
80
82
81
<span class="before:content-['ยท']">
···
145
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">
146
145
<div class="flex gap-2 items-center px-6">
147
146
<div class="flex-grow min-w-0 w-full py-2">
148
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
147
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
149
148
</div>
150
149
</div>
151
150
</a>
+3
-1
appview/pages/templates/repo/settings/general.html
+3
-1
appview/pages/templates/repo/settings/general.html
···
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
9
{{ template "branchSettings" . }}
10
10
{{ template "deleteRepo" . }}
11
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
11
12
</div>
12
13
</section>
13
14
{{ end }}
···
22
23
unless you specify a different branch.
23
24
</p>
24
25
</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
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
26
27
<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
28
<option value="" disabled selected >
28
29
Choose a default branch
···
54
55
<button
55
56
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
57
type="button"
58
+
hx-swap="none"
57
59
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
58
60
hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?">
59
61
{{ i "trash-2" "size-4" }}
+9
-4
appview/pages/templates/repo/settings/pipelines.html
+9
-4
appview/pages/templates/repo/settings/pipelines.html
···
34
34
{{ else }}
35
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
36
<select
37
-
id="spindle"
37
+
id="spindle"
38
38
name="spindle"
39
-
required
39
+
required
40
40
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
-
<option value="" disabled>
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 }}
42
44
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
43
48
</option>
44
49
{{ range $.Spindles }}
45
50
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
···
82
87
{{ end }}
83
88
84
89
{{ define "addSecretButton" }}
85
-
<button
90
+
<button
86
91
class="btn flex items-center gap-2"
87
92
popovertarget="add-secret-modal"
88
93
popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+5
-5
appview/pages/templates/repo/tree.html
+5
-5
appview/pages/templates/repo/tree.html
···
54
54
55
55
{{ range .Files }}
56
56
<div class="grid grid-cols-12 gap-4 items-center py-1">
57
-
<div class="col-span-6 md:col-span-3">
57
+
<div class="col-span-8 md:col-span-4">
58
58
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }}
59
59
{{ $icon := "folder" }}
60
60
{{ $iconStyle := "size-4 fill-current" }}
61
61
62
62
{{ if .IsFile }}
63
63
{{ $icon = "file" }}
64
-
{{ $iconStyle = "flex-shrink-0 size-4" }}
64
+
{{ $iconStyle = "size-4" }}
65
65
{{ end }}
66
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
67
<div class="flex items-center gap-2">
68
-
{{ i $icon $iconStyle }}
68
+
{{ i $icon $iconStyle "flex-shrink-0" }}
69
69
<span class="truncate">{{ .Name }}</span>
70
70
</div>
71
71
</a>
72
72
</div>
73
73
74
-
<div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden">
74
+
<div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden">
75
75
{{ with .LastCommit }}
76
76
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a>
77
77
{{ end }}
78
78
</div>
79
79
80
-
<div class="col-span-6 md:col-span-2 text-right">
80
+
<div class="col-span-4 md:col-span-2 text-sm text-right">
81
81
{{ with .LastCommit }}
82
82
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
83
83
{{ end }}
-192
appview/pages/templates/settings.html
-192
appview/pages/templates/settings.html
···
1
-
{{ define "title" }}settings{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Settings</p>
6
-
</div>
7
-
<div class="flex flex-col">
8
-
{{ block "profile" . }} {{ end }}
9
-
{{ block "keys" . }} {{ end }}
10
-
{{ block "emails" . }} {{ end }}
11
-
</div>
12
-
{{ end }}
13
-
14
-
{{ define "profile" }}
15
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
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
-
{{ if .LoggedInUser.Handle }}
19
-
<dt class="font-bold">handle</dt>
20
-
<dd>@{{ .LoggedInUser.Handle }}</dd>
21
-
{{ end }}
22
-
<dt class="font-bold">did</dt>
23
-
<dd>{{ .LoggedInUser.Did }}</dd>
24
-
<dt class="font-bold">pds</dt>
25
-
<dd>{{ .LoggedInUser.Pds }}</dd>
26
-
</dl>
27
-
</section>
28
-
{{ end }}
29
-
30
-
{{ define "keys" }}
31
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
32
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
-
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
34
-
<div id="key-list" class="flex flex-col gap-6 mb-8">
35
-
{{ range $index, $key := .PubKeys }}
36
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
37
-
<div class="flex flex-col gap-1">
38
-
<div class="inline-flex items-center gap-4">
39
-
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
-
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
-
</div>
42
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
43
-
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
-
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
-
</div>
46
-
</div>
47
-
<button
48
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
-
title="Delete key"
50
-
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
-
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"
52
-
>
53
-
{{ i "trash-2" "w-5 h-5" }}
54
-
<span class="hidden md:inline">delete</span>
55
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
56
-
</button>
57
-
</div>
58
-
{{ end }}
59
-
</div>
60
-
<form
61
-
hx-put="/settings/keys"
62
-
hx-indicator="#add-sshkey-spinner"
63
-
hx-swap="none"
64
-
class="max-w-2xl mb-8 space-y-4"
65
-
>
66
-
<input
67
-
type="text"
68
-
id="name"
69
-
name="name"
70
-
placeholder="key name"
71
-
required
72
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
73
-
74
-
<input
75
-
id="key"
76
-
name="key"
77
-
placeholder="ssh-rsa AAAAAA..."
78
-
required
79
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
80
-
81
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
82
-
<span>add key</span>
83
-
<span id="add-sshkey-spinner" class="group">
84
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
85
-
</span>
86
-
</button>
87
-
88
-
<div id="settings-keys" class="error dark:text-red-400"></div>
89
-
</form>
90
-
</section>
91
-
{{ end }}
92
-
93
-
{{ define "emails" }}
94
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
95
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
96
-
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
97
-
<div id="email-list" class="flex flex-col gap-6 mb-8">
98
-
{{ range $index, $email := .Emails }}
99
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
100
-
<div class="flex flex-col gap-2">
101
-
<div class="inline-flex items-center gap-4">
102
-
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
103
-
<p class="font-bold dark:text-white">{{ .Address }}</p>
104
-
<div class="inline-flex items-center gap-1">
105
-
{{ if .Verified }}
106
-
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
107
-
{{ else }}
108
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
109
-
{{ end }}
110
-
{{ if .Primary }}
111
-
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
112
-
{{ end }}
113
-
</div>
114
-
</div>
115
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
116
-
</div>
117
-
<div class="flex gap-2 items-center">
118
-
{{ if not .Verified }}
119
-
<button
120
-
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
121
-
hx-post="/settings/emails/verify/resend"
122
-
hx-swap="none"
123
-
href="#"
124
-
hx-vals='{"email": "{{ .Address }}"}'>
125
-
{{ i "rotate-cw" "w-5 h-5" }}
126
-
<span class="hidden md:inline">resend</span>
127
-
</button>
128
-
{{ end }}
129
-
{{ if and (not .Primary) .Verified }}
130
-
<a
131
-
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
132
-
hx-post="/settings/emails/primary"
133
-
hx-swap="none"
134
-
href="#"
135
-
hx-vals='{"email": "{{ .Address }}"}'>
136
-
set as primary
137
-
</a>
138
-
{{ end }}
139
-
{{ if not .Primary }}
140
-
<form
141
-
hx-delete="/settings/emails"
142
-
hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"
143
-
hx-indicator="#delete-email-{{ $index }}-spinner"
144
-
>
145
-
<input type="hidden" name="email" value="{{ .Address }}">
146
-
<button
147
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
148
-
title="Delete email"
149
-
type="submit"
150
-
>
151
-
{{ i "trash-2" "w-5 h-5" }}
152
-
<span class="hidden md:inline">delete</span>
153
-
<span id="delete-email-{{ $index }}-spinner" class="group">
154
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
155
-
</span>
156
-
</button>
157
-
</form>
158
-
{{ end }}
159
-
</div>
160
-
</div>
161
-
{{ end }}
162
-
</div>
163
-
<form
164
-
hx-put="/settings/emails"
165
-
hx-swap="none"
166
-
class="max-w-2xl mb-8 space-y-4"
167
-
hx-indicator="#add-email-spinner"
168
-
>
169
-
<input
170
-
type="email"
171
-
id="email"
172
-
name="email"
173
-
placeholder="your@email.com"
174
-
required
175
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
176
-
>
177
-
178
-
<button
179
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center"
180
-
type="submit"
181
-
>
182
-
<span>add email</span>
183
-
<span id="add-email-spinner" class="group">
184
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
185
-
</span>
186
-
</button>
187
-
188
-
<div id="settings-emails-error" class="error dark:text-red-400"></div>
189
-
<div id="settings-emails-success" class="success dark:text-green-400"></div>
190
-
</form>
191
-
</section>
192
-
{{ end }}
+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
+2
-2
appview/pages/templates/spindles/fragments/addMemberModal.html
+2
-2
appview/pages/templates/spindles/fragments/addMemberModal.html
···
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
16
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
-
{{ block "addMemberPopover" . }} {{ end }}
17
+
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
18
</div>
19
19
{{ end }}
20
20
21
-
{{ define "addMemberPopover" }}
21
+
{{ define "addSpindleMemberPopover" }}
22
22
<form
23
23
hx-post="/spindles/{{ .Instance }}/add"
24
24
hx-indicator="#spinner"
+11
-9
appview/pages/templates/spindles/fragments/spindleListing.html
+11
-9
appview/pages/templates/spindles/fragments/spindleListing.html
···
1
1
{{ define "spindles/fragments/spindleListing" }}
2
2
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
-
{{ block "leftSide" . }} {{ end }}
4
-
{{ block "rightSide" . }} {{ end }}
3
+
{{ block "spindleLeftSide" . }} {{ end }}
4
+
{{ block "spindleRightSide" . }} {{ end }}
5
5
</div>
6
6
{{ end }}
7
7
8
-
{{ define "leftSide" }}
8
+
{{ define "spindleLeftSide" }}
9
9
{{ if .Verified }}
10
10
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
11
{{ i "hard-drive" "w-4 h-4" }}
12
-
{{ .Instance }}
12
+
<span class="hover:underline">
13
+
{{ .Instance }}
14
+
</span>
13
15
<span class="text-gray-500">
14
16
{{ template "repo/fragments/shortTimeAgo" .Created }}
15
17
</span>
···
25
27
{{ end }}
26
28
{{ end }}
27
29
28
-
{{ define "rightSide" }}
30
+
{{ define "spindleRightSide" }}
29
31
<div id="right-side" class="flex gap-2">
30
32
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
31
33
{{ if .Verified }}
···
33
35
{{ template "spindles/fragments/addMemberModal" . }}
34
36
{{ else }}
35
37
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
36
-
{{ block "retryButton" . }} {{ end }}
38
+
{{ block "spindleRetryButton" . }} {{ end }}
37
39
{{ end }}
38
-
{{ block "deleteButton" . }} {{ end }}
40
+
{{ block "spindleDeleteButton" . }} {{ end }}
39
41
</div>
40
42
{{ end }}
41
43
42
-
{{ define "deleteButton" }}
44
+
{{ define "spindleDeleteButton" }}
43
45
<button
44
46
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
45
47
title="Delete spindle"
···
55
57
{{ end }}
56
58
57
59
58
-
{{ define "retryButton" }}
60
+
{{ define "spindleRetryButton" }}
59
61
<button
60
62
class="btn gap-2 group"
61
63
title="Retry spindle verification"
+3
-2
appview/pages/templates/strings/fragments/form.html
+3
-2
appview/pages/templates/strings/fragments/form.html
···
13
13
type="text"
14
14
id="filename"
15
15
name="filename"
16
-
placeholder="Filename with extension"
16
+
placeholder="Filename"
17
17
required
18
18
value="{{ .String.Filename }}"
19
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"
···
31
31
name="content"
32
32
id="content-textarea"
33
33
wrap="off"
34
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono"
35
35
rows="20"
36
+
spellcheck="false"
36
37
placeholder="Paste your string here!"
37
38
required>{{ .String.Contents }}</textarea>
38
39
<div class="flex justify-between items-center">
-4
appview/pages/templates/strings/put.html
-4
appview/pages/templates/strings/put.html
+15
-16
appview/pages/templates/strings/string.html
+15
-16
appview/pages/templates/strings/string.html
···
8
8
<meta property="og:description" content="{{ .String.Description }}" />
9
9
{{ end }}
10
10
11
-
{{ define "topbar" }}
12
-
{{ template "layouts/topbar" $ }}
13
-
{{ end }}
14
-
15
11
{{ define "content" }}
16
12
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
13
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
···
19
15
<div>
20
16
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
21
17
<span class="select-none">/</span>
22
-
<a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
18
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
23
19
</div>
24
20
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
25
21
<div class="flex gap-2 text-base">
···
35
31
title="Delete string"
36
32
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
37
33
hx-swap="none"
38
-
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
34
+
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
39
35
>
40
36
{{ i "trash-2" "size-4" }}
41
37
<span class="hidden md:inline">delete</span>
···
44
40
</div>
45
41
{{ end }}
46
42
</div>
47
-
<span class="flex items-center">
43
+
<span>
48
44
{{ with .String.Description }}
49
45
{{ . }}
50
-
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
51
-
{{ end }}
52
-
53
-
{{ with .String.Edited }}
54
-
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
55
-
{{ else }}
56
-
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
57
46
{{ end }}
58
47
</span>
59
48
</section>
60
49
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
61
50
<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">
62
-
<span>{{ .String.Filename }}</span>
51
+
<span>
52
+
{{ .String.Filename }}
53
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
54
+
<span>
55
+
{{ with .String.Edited }}
56
+
edited {{ template "repo/fragments/shortTimeAgo" . }}
57
+
{{ else }}
58
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
59
+
{{ end }}
60
+
</span>
61
+
</span>
63
62
<div>
64
63
<span>{{ .Stats.LineCount }} lines</span>
65
64
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
···
74
73
{{ end }}
75
74
</div>
76
75
</div>
77
-
<div class="overflow-auto relative">
76
+
<div class="overflow-x-auto overflow-y-hidden relative">
78
77
{{ if .ShowRendered }}
79
78
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
80
79
{{ else }}
+61
appview/pages/templates/strings/timeline.html
+61
appview/pages/templates/strings/timeline.html
···
1
+
{{ define "title" }} all strings {{ end }}
2
+
3
+
{{ define "content" }}
4
+
{{ block "timeline" $ }}{{ end }}
5
+
{{ end }}
6
+
7
+
{{ define "timeline" }}
8
+
<div>
9
+
<div class="p-6">
10
+
<p class="text-xl font-bold dark:text-white">All strings</p>
11
+
</div>
12
+
13
+
<div class="flex flex-col gap-4">
14
+
{{ range $i, $s := .Strings }}
15
+
<div class="relative">
16
+
{{ if ne $i 0 }}
17
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
18
+
{{ end }}
19
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
20
+
{{ template "stringCard" $s }}
21
+
</div>
22
+
</div>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "stringCard" }}
29
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
+
<div class="font-medium dark:text-white flex gap-2 items-center">
31
+
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
32
+
</div>
33
+
{{ with .Description }}
34
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
35
+
{{ . }}
36
+
</div>
37
+
{{ end }}
38
+
39
+
{{ template "stringCardInfo" . }}
40
+
</div>
41
+
{{ end }}
42
+
43
+
{{ define "stringCardInfo" }}
44
+
{{ $stat := .Stats }}
45
+
{{ $resolved := resolve .Did.String }}
46
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
+
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
+
{{ template "user/fragments/picHandle" $resolved }}
49
+
</a>
50
+
<span class="select-none [&:before]:content-['ยท']"></span>
51
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
+
<span class="select-none [&:before]:content-['ยท']"></span>
53
+
{{ with .Edited }}
54
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
55
+
{{ else }}
56
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
57
+
{{ end }}
58
+
</div>
59
+
{{ end }}
60
+
61
+
+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">
175
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
+
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
177
+
<span class="select-none after:content-['ยท']"></span>
178
+
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
179
+
</div>
180
+
{{ end }}
181
+
</div>
182
+
</div>
183
+
{{ end }}
-161
appview/pages/templates/timeline.html
-161
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="/signup" class="no-underline hover:no-underline ">
38
-
<button class="btn-create 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-4">
53
-
{{ range $i, $e := .Timeline }}
54
-
<div class="relative">
55
-
{{ if ne $i 0 }}
56
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
57
-
{{ end }}
58
-
{{ with $e }}
59
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
60
-
{{ if .Repo }}
61
-
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
62
-
{{ else if .Star }}
63
-
{{ block "starEvent" (list $ .Star) }} {{ end }}
64
-
{{ else if .Follow }}
65
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
66
-
{{ end }}
67
-
</div>
68
-
{{ end }}
69
-
</div>
70
-
{{ end }}
71
-
</div>
72
-
</div>
73
-
{{ end }}
74
-
75
-
{{ define "repoEvent" }}
76
-
{{ $root := index . 0 }}
77
-
{{ $repo := index . 1 }}
78
-
{{ $source := index . 2 }}
79
-
{{ $userHandle := index $root.DidHandleMap $repo.Did }}
80
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
81
-
{{ template "user/fragments/picHandleLink" $userHandle }}
82
-
{{ with $source }}
83
-
forked
84
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline">
85
-
{{ index $root.DidHandleMap .Did }}/{{ .Name }}
86
-
</a>
87
-
to
88
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
89
-
{{ else }}
90
-
created
91
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
92
-
{{ $repo.Name }}
93
-
</a>
94
-
{{ end }}
95
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
96
-
</div>
97
-
{{ with $repo }}
98
-
{{ template "user/fragments/repoCard" (list $root . true) }}
99
-
{{ end }}
100
-
{{ end }}
101
-
102
-
{{ define "starEvent" }}
103
-
{{ $root := index . 0 }}
104
-
{{ $star := index . 1 }}
105
-
{{ with $star }}
106
-
{{ $starrerHandle := index $root.DidHandleMap .StarredByDid }}
107
-
{{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }}
108
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
109
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
110
-
starred
111
-
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
112
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
113
-
</a>
114
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
115
-
</div>
116
-
{{ with .Repo }}
117
-
{{ template "user/fragments/repoCard" (list $root . true) }}
118
-
{{ end }}
119
-
{{ end }}
120
-
{{ end }}
121
-
122
-
123
-
{{ define "followEvent" }}
124
-
{{ $root := index . 0 }}
125
-
{{ $follow := index . 1 }}
126
-
{{ $profile := index . 2 }}
127
-
{{ $stat := index . 3 }}
128
-
129
-
{{ $userHandle := index $root.DidHandleMap $follow.UserDid }}
130
-
{{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }}
131
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
132
-
{{ template "user/fragments/picHandleLink" $userHandle }}
133
-
followed
134
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
135
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
136
-
</div>
137
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
138
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
139
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
140
-
</div>
141
-
142
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
143
-
<a href="/{{ $subjectHandle }}">
144
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
145
-
</a>
146
-
{{ with $profile }}
147
-
{{ with .Description }}
148
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
149
-
{{ end }}
150
-
{{ end }}
151
-
{{ with $stat }}
152
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
153
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
154
-
<span id="followers">{{ .Followers }} followers</span>
155
-
<span class="select-none after:content-['ยท']"></span>
156
-
<span id="following">{{ .Following }} following</span>
157
-
</div>
158
-
{{ end }}
159
-
</div>
160
-
</div>
161
-
{{ end }}
+18
appview/pages/templates/user/followers.html
+18
appview/pages/templates/user/followers.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "followers" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "followers" }}
10
+
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
+
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
+
{{ range .Followers }}
13
+
{{ template "user/fragments/followCard" . }}
14
+
{{ else }}
15
+
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
+
{{ end }}
17
+
</div>
18
+
{{ end }}
+18
appview/pages/templates/user/following.html
+18
appview/pages/templates/user/following.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "following" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "following" }}
10
+
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
+
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
+
{{ range .Following }}
13
+
{{ template "user/fragments/followCard" . }}
14
+
{{ else }}
15
+
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
+
{{ end }}
17
+
</div>
18
+
{{ end }}
+1
-1
appview/pages/templates/user/fragments/editBio.html
+1
-1
appview/pages/templates/user/fragments/editBio.html
+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>
+2
-2
appview/pages/templates/user/fragments/follow.html
+2
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
-
<button id="followBtn"
2
+
<button id="{{ normalizeForHtmlId .UserDid }}"
3
3
class="btn mt-2 w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
···
9
9
{{ end }}
10
10
11
11
hx-trigger="click"
12
-
hx-target="#followBtn"
12
+
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
13
hx-swap="outerHTML"
14
14
>
15
15
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
+29
appview/pages/templates/user/fragments/followCard.html
···
1
+
{{ define "user/fragments/followCard" }}
2
+
{{ $userIdent := resolve .UserDid }}
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
4
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
+
</div>
8
+
9
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
+
<a href="/{{ $userIdent }}">
11
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
+
</a>
13
+
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
+
<span class="select-none after:content-['ยท']"></span>
18
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
19
+
</div>
20
+
</div>
21
+
22
+
{{ if ne .FollowStatus.String "IsSelf" }}
23
+
<div class="max-w-24">
24
+
{{ template "user/fragments/follow" . }}
25
+
</div>
26
+
{{ end }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
+3
-2
appview/pages/templates/user/fragments/picHandleLink.html
+3
-2
appview/pages/templates/user/fragments/picHandleLink.html
···
1
1
{{ define "user/fragments/picHandleLink" }}
2
-
<a href="/{{ . }}" class="flex items-center">
3
-
{{ template "user/fragments/picHandle" . }}
2
+
{{ $resolved := resolve . }}
3
+
<a href="/{{ $resolved }}" class="flex items-center">
4
+
{{ template "user/fragments/picHandle" $resolved }}
4
5
</a>
5
6
{{ end }}
+21
-17
appview/pages/templates/user/fragments/profileCard.html
+21
-17
appview/pages/templates/user/fragments/profileCard.html
···
1
1
{{ define "user/fragments/profileCard" }}
2
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
2
+
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
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
5
<div class="w-3/4 aspect-square relative">
···
7
7
</div>
8
8
</div>
9
9
<div class="col-span-2">
10
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
11
-
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
12
-
{{ didOrHandle .UserDid .UserHandle }}
13
-
</p>
10
+
<div class="flex items-center flex-row flex-nowrap gap-2">
11
+
<p title="{{ $userIdent }}"
12
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
+
{{ $userIdent }}
14
+
</p>
15
+
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
+
</div>
14
17
15
18
<div class="md:hidden">
16
-
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
19
+
{{ block "followerFollowing" (list . $userIdent) }} {{ end }}
17
20
</div>
18
21
</div>
19
22
<div class="col-span-3 md:col-span-full">
···
26
29
{{ end }}
27
30
28
31
<div class="hidden md:block">
29
-
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
32
+
{{ block "followerFollowing" (list $ $userIdent) }} {{ end }}
30
33
</div>
31
34
32
35
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
···
39
42
{{ if .IncludeBluesky }}
40
43
<div class="flex items-center gap-2">
41
44
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
42
-
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
45
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a>
43
46
</div>
44
47
{{ end }}
45
48
{{ range $link := .Links }}
···
81
84
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
82
85
</div>
83
86
</div>
84
-
</div>
85
87
{{ end }}
86
88
87
89
{{ define "followerFollowing" }}
88
-
{{ $followers := index . 0 }}
89
-
{{ $following := index . 1 }}
90
-
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
91
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
92
-
<span id="followers">{{ $followers }} followers</span>
93
-
<span class="select-none after:content-['ยท']"></span>
94
-
<span id="following">{{ $following }} following</span>
95
-
</div>
90
+
{{ $root := index . 0 }}
91
+
{{ $userIdent := index . 1 }}
92
+
{{ with $root }}
93
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
94
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
95
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span>
96
+
<span class="select-none after:content-['ยท']"></span>
97
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
98
+
</div>
99
+
{{ end }}
96
100
{{ end }}
97
101
+40
-34
appview/pages/templates/user/fragments/repoCard.html
+40
-34
appview/pages/templates/user/fragments/repoCard.html
···
4
4
{{ $fullName := index . 2 }}
5
5
6
6
{{ with $repo }}
7
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
8
-
<div class="font-medium dark:text-white flex gap-2 items-center">
9
-
{{- if $fullName -}}
10
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a>
11
-
{{- else -}}
12
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a>
13
-
{{- end -}}
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 }}
14
25
</div>
15
-
{{ with .Description }}
16
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
17
-
{{ . }}
18
-
</div>
19
-
{{ end }}
26
+
{{ end }}
20
27
21
-
{{ if .RepoStats }}
22
-
{{ block "repoStats" .RepoStats }} {{ end }}
23
-
{{ end }}
28
+
{{ if .RepoStats }}
29
+
{{ block "repoStats" .RepoStats }}{{ end }}
30
+
{{ end }}
24
31
</div>
25
32
{{ end }}
26
33
{{ end }}
27
34
28
35
{{ define "repoStats" }}
29
-
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
36
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
30
37
{{ with .Language }}
31
-
<div class="flex gap-2 items-center text-sm">
32
-
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
-
<span>{{ . }}</span>
34
-
</div>
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>
35
43
{{ end }}
36
44
{{ with .StarCount }}
37
-
<div class="flex gap-1 items-center text-sm">
38
-
{{ i "star" "w-3 h-3 fill-current" }}
39
-
<span>{{ . }}</span>
40
-
</div>
45
+
<div class="flex gap-1 items-center text-sm">
46
+
{{ i "star" "w-3 h-3 fill-current" }}
47
+
<span>{{ . }}</span>
48
+
</div>
41
49
{{ end }}
42
50
{{ with .IssueCount.Open }}
43
-
<div class="flex gap-1 items-center text-sm">
44
-
{{ i "circle-dot" "w-3 h-3" }}
45
-
<span>{{ . }}</span>
46
-
</div>
51
+
<div class="flex gap-1 items-center text-sm">
52
+
{{ i "circle-dot" "w-3 h-3" }}
53
+
<span>{{ . }}</span>
54
+
</div>
47
55
{{ end }}
48
56
{{ with .PullCount.Open }}
49
-
<div class="flex gap-1 items-center text-sm">
50
-
{{ i "git-pull-request" "w-3 h-3" }}
51
-
<span>{{ . }}</span>
52
-
</div>
57
+
<div class="flex gap-1 items-center text-sm">
58
+
{{ i "git-pull-request" "w-3 h-3" }}
59
+
<span>{{ . }}</span>
60
+
</div>
53
61
{{ end }}
54
62
</div>
55
63
{{ end }}
56
-
57
-
+1
appview/pages/templates/user/login.html
+1
appview/pages/templates/user/login.html
+258
appview/pages/templates/user/overview.html
+258
appview/pages/templates/user/overview.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
5
+
<div class="grid grid-cols-1 gap-4">
6
+
{{ block "ownRepos" . }}{{ end }}
7
+
{{ block "collaboratingRepos" . }}{{ end }}
8
+
</div>
9
+
</div>
10
+
<div class="md:col-span-4 order-3 md:order-3">
11
+
{{ block "profileTimeline" . }}{{ end }}
12
+
</div>
13
+
{{ end }}
14
+
15
+
{{ define "profileTimeline" }}
16
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
17
+
<div class="flex flex-col gap-4 relative">
18
+
{{ if .ProfileTimeline.IsEmpty }}
19
+
<p class="dark:text-white">This user does not have any activity yet.</p>
20
+
{{ end }}
21
+
22
+
{{ with .ProfileTimeline }}
23
+
{{ range $idx, $byMonth := .ByMonth }}
24
+
{{ with $byMonth }}
25
+
{{ if not .IsEmpty }}
26
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6">
27
+
<p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400">
28
+
{{ if eq $idx 0 }}
29
+
this month
30
+
{{ else }}
31
+
{{$idx}} month{{if ne $idx 1}}s{{end}} ago
32
+
{{ end }}
33
+
</p>
34
+
35
+
<div class="flex flex-col gap-1">
36
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
37
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
38
+
{{ block "pullEvents" .PullEvents }} {{ end }}
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
{{ end }}
43
+
{{ end }}
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
48
+
{{ define "repoEvents" }}
49
+
{{ if gt (len .) 0 }}
50
+
<details>
51
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
52
+
<div class="flex flex-wrap items-center gap-2">
53
+
{{ i "book-plus" "w-4 h-4" }}
54
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
55
+
</div>
56
+
</summary>
57
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
58
+
{{ range . }}
59
+
<div class="flex flex-wrap items-center gap-2">
60
+
<span class="text-gray-500 dark:text-gray-400">
61
+
{{ if .Source }}
62
+
{{ i "git-fork" "w-4 h-4" }}
63
+
{{ else }}
64
+
{{ i "book-plus" "w-4 h-4" }}
65
+
{{ end }}
66
+
</span>
67
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
68
+
{{- .Repo.Name -}}
69
+
</a>
70
+
</div>
71
+
{{ end }}
72
+
</div>
73
+
</details>
74
+
{{ end }}
75
+
{{ end }}
76
+
77
+
{{ define "issueEvents" }}
78
+
{{ $items := .Items }}
79
+
{{ $stats := .Stats }}
80
+
81
+
{{ if gt (len $items) 0 }}
82
+
<details>
83
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
84
+
<div class="flex flex-wrap items-center gap-2">
85
+
{{ i "circle-dot" "w-4 h-4" }}
86
+
87
+
<div>
88
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
89
+
</div>
90
+
91
+
{{ if gt $stats.Open 0 }}
92
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
93
+
{{$stats.Open}} open
94
+
</span>
95
+
{{ end }}
96
+
97
+
{{ if gt $stats.Closed 0 }}
98
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
99
+
{{$stats.Closed}} closed
100
+
</span>
101
+
{{ end }}
102
+
103
+
</div>
104
+
</summary>
105
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
106
+
{{ range $items }}
107
+
{{ $repoOwner := resolve .Metadata.Repo.Did }}
108
+
{{ $repoName := .Metadata.Repo.Name }}
109
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
110
+
111
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
112
+
{{ if .Open }}
113
+
<span class="text-green-600 dark:text-green-500">
114
+
{{ i "circle-dot" "w-4 h-4" }}
115
+
</span>
116
+
{{ else }}
117
+
<span class="text-gray-500 dark:text-gray-400">
118
+
{{ i "ban" "w-4 h-4" }}
119
+
</span>
120
+
{{ end }}
121
+
<div class="flex-none min-w-8 text-right">
122
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
123
+
</div>
124
+
<div class="break-words max-w-full">
125
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
126
+
{{ .Title -}}
127
+
</a>
128
+
on
129
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
130
+
{{$repoUrl}}
131
+
</a>
132
+
</div>
133
+
</div>
134
+
{{ end }}
135
+
</div>
136
+
</details>
137
+
{{ end }}
138
+
{{ end }}
139
+
140
+
{{ define "pullEvents" }}
141
+
{{ $items := .Items }}
142
+
{{ $stats := .Stats }}
143
+
{{ if gt (len $items) 0 }}
144
+
<details>
145
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
146
+
<div class="flex flex-wrap items-center gap-2">
147
+
{{ i "git-pull-request" "w-4 h-4" }}
148
+
149
+
<div>
150
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
151
+
</div>
152
+
153
+
{{ if gt $stats.Open 0 }}
154
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
155
+
{{$stats.Open}} open
156
+
</span>
157
+
{{ end }}
158
+
159
+
{{ if gt $stats.Merged 0 }}
160
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
161
+
{{$stats.Merged}} merged
162
+
</span>
163
+
{{ end }}
164
+
165
+
166
+
{{ if gt $stats.Closed 0 }}
167
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
168
+
{{$stats.Closed}} closed
169
+
</span>
170
+
{{ end }}
171
+
172
+
</div>
173
+
</summary>
174
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
175
+
{{ range $items }}
176
+
{{ $repoOwner := resolve .Repo.Did }}
177
+
{{ $repoName := .Repo.Name }}
178
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
179
+
180
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
181
+
{{ if .State.IsOpen }}
182
+
<span class="text-green-600 dark:text-green-500">
183
+
{{ i "git-pull-request" "w-4 h-4" }}
184
+
</span>
185
+
{{ else if .State.IsMerged }}
186
+
<span class="text-purple-600 dark:text-purple-500">
187
+
{{ i "git-merge" "w-4 h-4" }}
188
+
</span>
189
+
{{ else }}
190
+
<span class="text-gray-600 dark:text-gray-300">
191
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
192
+
</span>
193
+
{{ end }}
194
+
<div class="flex-none min-w-8 text-right">
195
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
196
+
</div>
197
+
<div class="break-words max-w-full">
198
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
199
+
{{ .Title -}}
200
+
</a>
201
+
on
202
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
203
+
{{$repoUrl}}
204
+
</a>
205
+
</div>
206
+
</div>
207
+
{{ end }}
208
+
</div>
209
+
</details>
210
+
{{ end }}
211
+
{{ end }}
212
+
213
+
{{ define "ownRepos" }}
214
+
<div>
215
+
<div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2">
216
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
217
+
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
218
+
<span>PINNED REPOS</span>
219
+
</a>
220
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
221
+
<button
222
+
hx-get="profile/edit-pins"
223
+
hx-target="#all-repos"
224
+
class="py-0 font-normal text-sm flex gap-2 items-center group">
225
+
{{ i "pencil" "w-3 h-3" }}
226
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
227
+
</button>
228
+
{{ end }}
229
+
</div>
230
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
231
+
{{ range .Repos }}
232
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
233
+
{{ template "user/fragments/repoCard" (list $ . false) }}
234
+
</div>
235
+
{{ else }}
236
+
<p class="dark:text-white">This user does not have any pinned repos.</p>
237
+
{{ end }}
238
+
</div>
239
+
</div>
240
+
{{ end }}
241
+
242
+
{{ define "collaboratingRepos" }}
243
+
{{ if gt (len .CollaboratingRepos) 0 }}
244
+
<div>
245
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p>
246
+
<div id="collaborating" class="grid grid-cols-1 gap-4">
247
+
{{ range .CollaboratingRepos }}
248
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
249
+
{{ template "user/fragments/repoCard" (list $ . true) }}
250
+
</div>
251
+
{{ else }}
252
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
253
+
{{ end }}
254
+
</div>
255
+
</div>
256
+
{{ end }}
257
+
{{ end }}
258
+
-325
appview/pages/templates/user/profile.html
-325
appview/pages/templates/user/profile.html
···
1
-
{{ define "title" }}{{ 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
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
<div class="grid grid-cols-1 gap-4">
14
-
{{ template "user/fragments/profileCard" .Card }}
15
-
{{ block "punchcard" .Punchcard }} {{ end }}
16
-
</div>
17
-
</div>
18
-
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
19
-
<div class="grid grid-cols-1 gap-4">
20
-
{{ block "ownRepos" . }}{{ end }}
21
-
{{ block "collaboratingRepos" . }}{{ end }}
22
-
</div>
23
-
</div>
24
-
<div class="md:col-span-4 order-3 md:order-3">
25
-
{{ block "profileTimeline" . }}{{ end }}
26
-
</div>
27
-
</div>
28
-
{{ end }}
29
-
30
-
{{ define "profileTimeline" }}
31
-
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
32
-
<div class="flex flex-col gap-4 relative">
33
-
{{ with .ProfileTimeline }}
34
-
{{ range $idx, $byMonth := .ByMonth }}
35
-
{{ with $byMonth }}
36
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
37
-
{{ if eq $idx 0 }}
38
-
39
-
{{ else }}
40
-
{{ $s := "s" }}
41
-
{{ if eq $idx 1 }}
42
-
{{ $s = "" }}
43
-
{{ end }}
44
-
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
45
-
{{ end }}
46
-
47
-
{{ if .IsEmpty }}
48
-
<div class="text-gray-500 dark:text-gray-400">
49
-
No activity for this month
50
-
</div>
51
-
{{ else }}
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 }}
56
-
</div>
57
-
{{ end }}
58
-
</div>
59
-
60
-
{{ end }}
61
-
{{ else }}
62
-
<p class="dark:text-white">This user does not have any activity yet.</p>
63
-
{{ end }}
64
-
{{ end }}
65
-
</div>
66
-
{{ end }}
67
-
68
-
{{ define "repoEvents" }}
69
-
{{ $items := index . 0 }}
70
-
{{ $handleMap := index . 1 }}
71
-
72
-
{{ if gt (len $items) 0 }}
73
-
<details>
74
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
75
-
<div class="flex flex-wrap items-center gap-2">
76
-
{{ i "book-plus" "w-4 h-4" }}
77
-
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
78
-
</div>
79
-
</summary>
80
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
81
-
{{ range $items }}
82
-
<div class="flex flex-wrap items-center gap-2">
83
-
<span class="text-gray-500 dark:text-gray-400">
84
-
{{ if .Source }}
85
-
{{ i "git-fork" "w-4 h-4" }}
86
-
{{ else }}
87
-
{{ i "book-plus" "w-4 h-4" }}
88
-
{{ end }}
89
-
</span>
90
-
<a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
91
-
{{- .Repo.Name -}}
92
-
</a>
93
-
</div>
94
-
{{ end }}
95
-
</div>
96
-
</details>
97
-
{{ end }}
98
-
{{ end }}
99
-
100
-
{{ define "issueEvents" }}
101
-
{{ $i := index . 0 }}
102
-
{{ $items := $i.Items }}
103
-
{{ $stats := $i.Stats }}
104
-
{{ $handleMap := index . 1 }}
105
-
106
-
{{ if gt (len $items) 0 }}
107
-
<details>
108
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
109
-
<div class="flex flex-wrap items-center gap-2">
110
-
{{ i "circle-dot" "w-4 h-4" }}
111
-
112
-
<div>
113
-
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
114
-
</div>
115
-
116
-
{{ if gt $stats.Open 0 }}
117
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
118
-
{{$stats.Open}} open
119
-
</span>
120
-
{{ end }}
121
-
122
-
{{ if gt $stats.Closed 0 }}
123
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
124
-
{{$stats.Closed}} closed
125
-
</span>
126
-
{{ end }}
127
-
128
-
</div>
129
-
</summary>
130
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
131
-
{{ range $items }}
132
-
{{ $repoOwner := index $handleMap .Metadata.Repo.Did }}
133
-
{{ $repoName := .Metadata.Repo.Name }}
134
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
135
-
136
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
137
-
{{ if .Open }}
138
-
<span class="text-green-600 dark:text-green-500">
139
-
{{ i "circle-dot" "w-4 h-4" }}
140
-
</span>
141
-
{{ else }}
142
-
<span class="text-gray-500 dark:text-gray-400">
143
-
{{ i "ban" "w-4 h-4" }}
144
-
</span>
145
-
{{ end }}
146
-
<div class="flex-none min-w-8 text-right">
147
-
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
148
-
</div>
149
-
<div class="break-words max-w-full">
150
-
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
151
-
{{ .Title -}}
152
-
</a>
153
-
on
154
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
155
-
{{$repoUrl}}
156
-
</a>
157
-
</div>
158
-
</div>
159
-
{{ end }}
160
-
</div>
161
-
</details>
162
-
{{ end }}
163
-
{{ end }}
164
-
165
-
{{ define "pullEvents" }}
166
-
{{ $i := index . 0 }}
167
-
{{ $items := $i.Items }}
168
-
{{ $stats := $i.Stats }}
169
-
{{ $handleMap := index . 1 }}
170
-
{{ if gt (len $items) 0 }}
171
-
<details>
172
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
173
-
<div class="flex flex-wrap items-center gap-2">
174
-
{{ i "git-pull-request" "w-4 h-4" }}
175
-
176
-
<div>
177
-
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
178
-
</div>
179
-
180
-
{{ if gt $stats.Open 0 }}
181
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
182
-
{{$stats.Open}} open
183
-
</span>
184
-
{{ end }}
185
-
186
-
{{ if gt $stats.Merged 0 }}
187
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
188
-
{{$stats.Merged}} merged
189
-
</span>
190
-
{{ end }}
191
-
192
-
193
-
{{ if gt $stats.Closed 0 }}
194
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
195
-
{{$stats.Closed}} closed
196
-
</span>
197
-
{{ end }}
198
-
199
-
</div>
200
-
</summary>
201
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
202
-
{{ range $items }}
203
-
{{ $repoOwner := index $handleMap .Repo.Did }}
204
-
{{ $repoName := .Repo.Name }}
205
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
206
-
207
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
208
-
{{ if .State.IsOpen }}
209
-
<span class="text-green-600 dark:text-green-500">
210
-
{{ i "git-pull-request" "w-4 h-4" }}
211
-
</span>
212
-
{{ else if .State.IsMerged }}
213
-
<span class="text-purple-600 dark:text-purple-500">
214
-
{{ i "git-merge" "w-4 h-4" }}
215
-
</span>
216
-
{{ else }}
217
-
<span class="text-gray-600 dark:text-gray-300">
218
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
219
-
</span>
220
-
{{ end }}
221
-
<div class="flex-none min-w-8 text-right">
222
-
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
223
-
</div>
224
-
<div class="break-words max-w-full">
225
-
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
226
-
{{ .Title -}}
227
-
</a>
228
-
on
229
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
230
-
{{$repoUrl}}
231
-
</a>
232
-
</div>
233
-
</div>
234
-
{{ end }}
235
-
</div>
236
-
</details>
237
-
{{ end }}
238
-
{{ end }}
239
-
240
-
{{ define "ownRepos" }}
241
-
<div>
242
-
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
243
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
244
-
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
245
-
<span>PINNED REPOS</span>
246
-
<span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
247
-
view all {{ i "chevron-right" "w-4 h-4" }}
248
-
</span>
249
-
</a>
250
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
251
-
<button
252
-
hx-get="profile/edit-pins"
253
-
hx-target="#all-repos"
254
-
class="btn py-0 font-normal text-sm flex gap-2 items-center group">
255
-
{{ i "pencil" "w-3 h-3" }}
256
-
edit
257
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
258
-
</button>
259
-
{{ end }}
260
-
</div>
261
-
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
262
-
{{ range .Repos }}
263
-
{{ template "user/fragments/repoCard" (list $ . false) }}
264
-
{{ else }}
265
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
266
-
{{ end }}
267
-
</div>
268
-
</div>
269
-
{{ end }}
270
-
271
-
{{ define "collaboratingRepos" }}
272
-
{{ if gt (len .CollaboratingRepos) 0 }}
273
-
<div>
274
-
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
275
-
<div id="collaborating" class="grid grid-cols-1 gap-4">
276
-
{{ range .CollaboratingRepos }}
277
-
{{ template "user/fragments/repoCard" (list $ . true) }}
278
-
{{ else }}
279
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
280
-
{{ end }}
281
-
</div>
282
-
</div>
283
-
{{ end }}
284
-
{{ end }}
285
-
286
-
{{ define "punchcard" }}
287
-
{{ $now := now }}
288
-
<div>
289
-
<p class="p-2 flex gap-2 text-sm font-bold dark:text-white">
290
-
PUNCHCARD
291
-
<span class="font-normal text-sm text-gray-500 dark:text-gray-400 ">
292
-
{{ .Total | int64 | commaFmt }} commits
293
-
</span>
294
-
</p>
295
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
296
-
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full">
297
-
{{ range .Punches }}
298
-
{{ $count := .Count }}
299
-
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
300
-
{{ if lt $count 1 }}
301
-
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
302
-
{{ else if lt $count 2 }}
303
-
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
304
-
{{ else if lt $count 4 }}
305
-
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
306
-
{{ else if lt $count 8 }}
307
-
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
308
-
{{ else }}
309
-
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
310
-
{{ end }}
311
-
312
-
{{ if .Date.After $now }}
313
-
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
314
-
{{ end }}
315
-
<div class="w-full h-full flex justify-center items-center">
316
-
<div
317
-
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
318
-
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
319
-
</div>
320
-
</div>
321
-
{{ end }}
322
-
</div>
323
-
</div>
324
-
</div>
325
-
{{ end }}
+7
-18
appview/pages/templates/user/repos.html
+7
-18
appview/pages/templates/user/repos.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "ownRepos" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "ownRepos" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "ownRepos" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
10
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
11
{{ range .Repos }}
25
-
{{ template "user/fragments/repoCard" (list $ . false) }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . false) }}
14
+
</div>
26
15
{{ else }}
27
16
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
28
17
{{ end }}
+94
appview/pages/templates/user/settings/emails.html
+94
appview/pages/templates/user/settings/emails.html
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "emailSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "emailSettings" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Commits authored using emails listed here will be associated with your Tangled profile.
25
+
</p>
26
+
</div>
27
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
28
+
{{ template "addEmailButton" . }}
29
+
</div>
30
+
</div>
31
+
<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">
32
+
{{ range .Emails }}
33
+
{{ template "user/settings/fragments/emailListing" (list $ .) }}
34
+
{{ else }}
35
+
<div class="flex items-center justify-center p-2 text-gray-500">
36
+
no emails added yet
37
+
</div>
38
+
{{ end }}
39
+
</div>
40
+
{{ end }}
41
+
42
+
{{ define "addEmailButton" }}
43
+
<button
44
+
class="btn flex items-center gap-2"
45
+
popovertarget="add-email-modal"
46
+
popovertargetaction="toggle">
47
+
{{ i "plus" "size-4" }}
48
+
add email
49
+
</button>
50
+
<div
51
+
id="add-email-modal"
52
+
popover
53
+
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">
54
+
{{ template "addEmailModal" . }}
55
+
</div>
56
+
{{ end}}
57
+
58
+
{{ define "addEmailModal" }}
59
+
<form
60
+
hx-put="/settings/emails"
61
+
hx-indicator="#spinner"
62
+
hx-swap="none"
63
+
class="flex flex-col gap-2"
64
+
>
65
+
<p class="uppercase p-0">ADD EMAIL</p>
66
+
<p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p>
67
+
<input
68
+
type="email"
69
+
id="email-address"
70
+
name="email"
71
+
required
72
+
placeholder="your@email.com"
73
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
74
+
/>
75
+
<div class="flex gap-2 pt-2">
76
+
<button
77
+
type="button"
78
+
popovertarget="add-email-modal"
79
+
popovertargetaction="hide"
80
+
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"
81
+
>
82
+
{{ i "x" "size-4" }} cancel
83
+
</button>
84
+
<button type="submit" class="btn w-1/2 flex items-center">
85
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
86
+
<span id="spinner" class="group">
87
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
+
</span>
89
+
</button>
90
+
</div>
91
+
<div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div>
92
+
<div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div>
93
+
</form>
94
+
{{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
+62
appview/pages/templates/user/settings/fragments/emailListing.html
···
1
+
{{ define "user/settings/fragments/emailListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $email := index . 1 }}
4
+
<div id="email-{{$email.Address}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
6
+
<div class="flex items-center gap-2">
7
+
{{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }}
8
+
<span class="font-bold">
9
+
{{ $email.Address }}
10
+
</span>
11
+
<div class="inline-flex items-center gap-1">
12
+
{{ if $email.Verified }}
13
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
14
+
{{ else }}
15
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
16
+
{{ end }}
17
+
{{ if $email.Primary }}
18
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
19
+
{{ end }}
20
+
</div>
21
+
</div>
22
+
<div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
23
+
<span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span>
24
+
</div>
25
+
</div>
26
+
<div class="flex gap-2 items-center">
27
+
{{ if not $email.Verified }}
28
+
<button
29
+
class="btn flex gap-2 text-sm px-2 py-1"
30
+
hx-post="/settings/emails/verify/resend"
31
+
hx-swap="none"
32
+
hx-vals='{"email": "{{ $email.Address }}"}'>
33
+
{{ i "rotate-cw" "w-4 h-4" }}
34
+
<span class="hidden md:inline">resend</span>
35
+
</button>
36
+
{{ end }}
37
+
{{ if and (not $email.Primary) $email.Verified }}
38
+
<button
39
+
class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
40
+
hx-post="/settings/emails/primary"
41
+
hx-swap="none"
42
+
hx-vals='{"email": "{{ $email.Address }}"}'>
43
+
set as primary
44
+
</button>
45
+
{{ end }}
46
+
{{ if not $email.Primary }}
47
+
<button
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
+
title="Delete email"
50
+
hx-delete="/settings/emails"
51
+
hx-swap="none"
52
+
hx-vals='{"email": "{{ $email.Address }}"}'
53
+
hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?"
54
+
>
55
+
{{ i "trash-2" "w-5 h-5" }}
56
+
<span class="hidden md:inline">delete</span>
57
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
+
</button>
59
+
{{ end }}
60
+
</div>
61
+
</div>
62
+
{{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
+31
appview/pages/templates/user/settings/fragments/keyListing.html
···
1
+
{{ define "user/settings/fragments/keyListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $key := index . 1 }}
4
+
<div id="key-{{$key.Name}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]">
6
+
<div class="flex items-center gap-2">
7
+
<span>{{ i "key" "w-4" "h-4" }}</span>
8
+
<span class="font-bold">
9
+
{{ $key.Name }}
10
+
</span>
11
+
</div>
12
+
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
13
+
{{ sshFingerprint $key.Key }}
14
+
</span>
15
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
16
+
<span>added {{ template "repo/fragments/time" $key.Created }}</span>
17
+
</div>
18
+
</div>
19
+
<button
20
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
21
+
title="Delete key"
22
+
hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}"
23
+
hx-swap="none"
24
+
hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?"
25
+
>
26
+
{{ i "trash-2" "w-5 h-5" }}
27
+
<span class="hidden md:inline">delete</span>
28
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
29
+
</button>
30
+
</div>
31
+
{{ end }}
+101
appview/pages/templates/user/settings/keys.html
+101
appview/pages/templates/user/settings/keys.html
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "sshKeysSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "sshKeysSettings" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
SSH public keys added here will be broadcasted to knots that you are a member of,
25
+
allowing you to push to repositories there.
26
+
</p>
27
+
</div>
28
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
29
+
{{ template "addKeyButton" . }}
30
+
</div>
31
+
</div>
32
+
<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">
33
+
{{ range .PubKeys }}
34
+
{{ template "user/settings/fragments/keyListing" (list $ .) }}
35
+
{{ else }}
36
+
<div class="flex items-center justify-center p-2 text-gray-500">
37
+
no keys added yet
38
+
</div>
39
+
{{ end }}
40
+
</div>
41
+
{{ end }}
42
+
43
+
{{ define "addKeyButton" }}
44
+
<button
45
+
class="btn flex items-center gap-2"
46
+
popovertarget="add-key-modal"
47
+
popovertargetaction="toggle">
48
+
{{ i "plus" "size-4" }}
49
+
add key
50
+
</button>
51
+
<div
52
+
id="add-key-modal"
53
+
popover
54
+
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">
55
+
{{ template "addKeyModal" . }}
56
+
</div>
57
+
{{ end}}
58
+
59
+
{{ define "addKeyModal" }}
60
+
<form
61
+
hx-put="/settings/keys"
62
+
hx-indicator="#spinner"
63
+
hx-swap="none"
64
+
class="flex flex-col gap-2"
65
+
>
66
+
<p class="uppercase p-0">ADD SSH KEY</p>
67
+
<p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
68
+
<input
69
+
type="text"
70
+
id="key-name"
71
+
name="name"
72
+
required
73
+
placeholder="key name"
74
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
75
+
/>
76
+
<textarea
77
+
type="text"
78
+
id="key-value"
79
+
name="key"
80
+
required
81
+
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
82
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea>
83
+
<div class="flex gap-2 pt-2">
84
+
<button
85
+
type="button"
86
+
popovertarget="add-key-modal"
87
+
popovertargetaction="hide"
88
+
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"
89
+
>
90
+
{{ i "x" "size-4" }} cancel
91
+
</button>
92
+
<button type="submit" class="btn w-1/2 flex items-center">
93
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
94
+
<span id="spinner" class="group">
95
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
96
+
</span>
97
+
</button>
98
+
</div>
99
+
<div id="settings-keys" class="text-red-500 dark:text-red-400"></div>
100
+
</form>
101
+
{{ end }}
+64
appview/pages/templates/user/settings/profile.html
+64
appview/pages/templates/user/settings/profile.html
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "profileInfo" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "profileInfo" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Profile</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Your account information from your AT Protocol identity.
25
+
</p>
26
+
</div>
27
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
28
+
</div>
29
+
</div>
30
+
<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">
31
+
<div class="flex items-center justify-between p-4">
32
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
33
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
+
<span>Handle</span>
35
+
</div>
36
+
{{ if .LoggedInUser.Handle }}
37
+
<span class="font-bold">
38
+
@{{ .LoggedInUser.Handle }}
39
+
</span>
40
+
{{ end }}
41
+
</div>
42
+
</div>
43
+
<div class="flex items-center justify-between p-4">
44
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
45
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
46
+
<span>Decentralized Identifier (DID)</span>
47
+
</div>
48
+
<span class="font-mono font-bold">
49
+
{{ .LoggedInUser.Did }}
50
+
</span>
51
+
</div>
52
+
</div>
53
+
<div class="flex items-center justify-between p-4">
54
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
55
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
56
+
<span>Personal Data Server (PDS)</span>
57
+
</div>
58
+
<span class="font-bold">
59
+
{{ .LoggedInUser.Pds }}
60
+
</span>
61
+
</div>
62
+
</div>
63
+
</div>
64
+
{{ end }}
+1
-1
appview/pages/templates/user/signup.html
+1
-1
appview/pages/templates/user/signup.html
···
42
42
</button>
43
43
</form>
44
44
<p class="text-sm text-gray-500">
45
-
Already have an account? <a href="/login" class="underline">Login to Tangled</a>.
45
+
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
46
46
</p>
47
47
48
48
<p id="signup-msg" class="error w-full"></p>
+19
appview/pages/templates/user/starred.html
+19
appview/pages/templates/user/starred.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "starredRepos" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "starredRepos" }}
10
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Repos }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . true) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
+45
appview/pages/templates/user/strings.html
+45
appview/pages/templates/user/strings.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "allStrings" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "allStrings" }}
10
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Strings }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "singleString" (list $ .) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "singleString" }}
22
+
{{ $root := index . 0 }}
23
+
{{ $s := index . 1 }}
24
+
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
25
+
<div class="font-medium dark:text-white flex gap-2 items-center">
26
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
27
+
</div>
28
+
{{ with $s.Description }}
29
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
30
+
{{ . }}
31
+
</div>
32
+
{{ end }}
33
+
34
+
{{ $stat := $s.Stats }}
35
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
36
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
37
+
<span class="select-none [&:before]:content-['ยท']"></span>
38
+
{{ with $s.Edited }}
39
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
40
+
{{ else }}
41
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
+172
-198
appview/pulls/pulls.go
+172
-198
appview/pulls/pulls.go
···
2
2
3
3
import (
4
4
"database/sql"
5
-
"encoding/json"
6
5
"errors"
7
6
"fmt"
8
-
"io"
9
7
"log"
10
8
"net/http"
11
9
"sort"
···
19
17
"tangled.sh/tangled.sh/core/appview/notify"
20
18
"tangled.sh/tangled.sh/core/appview/oauth"
21
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
21
"tangled.sh/tangled.sh/core/appview/reporesolver"
22
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
23
23
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/patchutil"
···
28
28
29
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
30
comatproto "github.com/bluesky-social/indigo/api/atproto"
31
-
"github.com/bluesky-social/indigo/atproto/syntax"
32
31
lexutil "github.com/bluesky-social/indigo/lex/util"
32
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
33
33
"github.com/go-chi/chi/v5"
34
34
"github.com/google/uuid"
35
35
)
···
96
96
return
97
97
}
98
98
99
-
mergeCheckResponse := s.mergeCheck(f, pull, stack)
99
+
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
100
100
resubmitResult := pages.Unknown
101
101
if user.Did == pull.OwnerDid {
102
102
resubmitResult = s.resubmitCheck(f, pull, stack)
···
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
-
mergeCheckResponse := s.mergeCheck(f, pull, stack)
154
+
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
165
155
resubmitResult := pages.Unknown
166
156
if user != nil && user.Did == pull.OwnerDid {
167
157
resubmitResult = s.resubmitCheck(f, pull, stack)
···
212
202
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
213
203
LoggedInUser: user,
214
204
RepoInfo: repoInfo,
215
-
DidHandleMap: didHandleMap,
216
205
Pull: pull,
217
206
Stack: stack,
218
207
AbandonedPulls: abandonedPulls,
···
226
215
})
227
216
}
228
217
229
-
func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
218
+
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
230
219
if pull.State == db.PullMerged {
231
220
return types.MergeCheckResponse{}
232
221
}
233
222
234
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
235
-
if err != nil {
236
-
log.Printf("failed to get registration key: %v", err)
237
-
return types.MergeCheckResponse{
238
-
Error: "failed to check merge status: this knot is unregistered",
239
-
}
223
+
scheme := "https"
224
+
if s.config.Core.Dev {
225
+
scheme = "http"
240
226
}
227
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
241
228
242
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
243
-
if err != nil {
244
-
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
245
-
return types.MergeCheckResponse{
246
-
Error: "failed to check merge status",
247
-
}
229
+
xrpcc := indigoxrpc.Client{
230
+
Host: host,
248
231
}
249
232
250
233
patch := pull.LatestPatch()
···
257
240
patch = mergeable.CombinedPatch()
258
241
}
259
242
260
-
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
261
-
if err != nil {
262
-
log.Println("failed to check for mergeability:", err)
243
+
resp, xe := tangled.RepoMergeCheck(
244
+
r.Context(),
245
+
&xrpcc,
246
+
&tangled.RepoMergeCheck_Input{
247
+
Did: f.OwnerDid(),
248
+
Name: f.Name,
249
+
Branch: pull.TargetBranch,
250
+
Patch: patch,
251
+
},
252
+
)
253
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
254
+
log.Println("failed to check for mergeability", "err", err)
263
255
return types.MergeCheckResponse{
264
-
Error: "failed to check merge status",
256
+
Error: fmt.Sprintf("failed to check merge status: %s", err.Error()),
265
257
}
266
258
}
267
-
switch resp.StatusCode {
268
-
case 404:
269
-
return types.MergeCheckResponse{
270
-
Error: "failed to check merge status: this knot does not support PRs",
271
-
}
272
-
case 400:
273
-
return types.MergeCheckResponse{
274
-
Error: "failed to check merge status: does this knot support PRs?",
259
+
260
+
// convert xrpc response to internal types
261
+
conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
262
+
for i, conflict := range resp.Conflicts {
263
+
conflicts[i] = types.ConflictInfo{
264
+
Filename: conflict.Filename,
265
+
Reason: conflict.Reason,
275
266
}
276
267
}
277
268
278
-
respBody, err := io.ReadAll(resp.Body)
279
-
if err != nil {
280
-
log.Println("failed to read merge check response body")
281
-
return types.MergeCheckResponse{
282
-
Error: "failed to check merge status: knot is not speaking the right language",
283
-
}
269
+
result := types.MergeCheckResponse{
270
+
IsConflicted: resp.Is_conflicted,
271
+
Conflicts: conflicts,
272
+
}
273
+
274
+
if resp.Message != nil {
275
+
result.Message = *resp.Message
284
276
}
285
-
defer resp.Body.Close()
286
277
287
-
var mergeCheckResponse types.MergeCheckResponse
288
-
err = json.Unmarshal(respBody, &mergeCheckResponse)
289
-
if err != nil {
290
-
log.Println("failed to unmarshal merge check response", err)
291
-
return types.MergeCheckResponse{
292
-
Error: "failed to check merge status: knot is not speaking the right language",
293
-
}
278
+
if resp.Error != nil {
279
+
result.Error = *resp.Error
294
280
}
295
281
296
-
return mergeCheckResponse
282
+
return result
297
283
}
298
284
299
285
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
···
318
304
// pulls within the same repo
319
305
knot = f.Knot
320
306
ownerDid = f.OwnerDid()
321
-
repoName = f.RepoName
307
+
repoName = f.Name
322
308
}
323
309
324
310
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
···
377
363
return
378
364
}
379
365
380
-
identsToResolve := []string{pull.OwnerDid}
381
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
382
-
didHandleMap := make(map[string]string)
383
-
for _, identity := range resolvedIds {
384
-
if !identity.Handle.IsInvalidHandle() {
385
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
386
-
} else {
387
-
didHandleMap[identity.DID.String()] = identity.DID.String()
388
-
}
389
-
}
390
-
391
366
patch := pull.Submissions[roundIdInt].Patch
392
367
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
393
368
394
369
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
395
370
LoggedInUser: user,
396
-
DidHandleMap: didHandleMap,
397
371
RepoInfo: f.RepoInfo(user),
398
372
Pull: pull,
399
373
Stack: stack,
···
440
414
return
441
415
}
442
416
443
-
identsToResolve := []string{pull.OwnerDid}
444
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
445
-
didHandleMap := make(map[string]string)
446
-
for _, identity := range resolvedIds {
447
-
if !identity.Handle.IsInvalidHandle() {
448
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
449
-
} else {
450
-
didHandleMap[identity.DID.String()] = identity.DID.String()
451
-
}
452
-
}
453
-
454
417
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
455
418
if err != nil {
456
419
log.Println("failed to interdiff; current patch malformed")
···
472
435
RepoInfo: f.RepoInfo(user),
473
436
Pull: pull,
474
437
Round: roundIdInt,
475
-
DidHandleMap: didHandleMap,
476
438
Interdiff: interdiff,
477
439
DiffOpts: diffOpts,
478
440
})
···
494
456
return
495
457
}
496
458
497
-
identsToResolve := []string{pull.OwnerDid}
498
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
499
-
didHandleMap := make(map[string]string)
500
-
for _, identity := range resolvedIds {
501
-
if !identity.Handle.IsInvalidHandle() {
502
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
503
-
} else {
504
-
didHandleMap[identity.DID.String()] = identity.DID.String()
505
-
}
506
-
}
507
-
508
459
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
509
460
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
510
461
}
···
529
480
530
481
pulls, err := db.GetPulls(
531
482
s.db,
532
-
db.FilterEq("repo_at", f.RepoAt),
483
+
db.FilterEq("repo_at", f.RepoAt()),
533
484
db.FilterEq("state", state),
534
485
)
535
486
if err != nil {
···
595
546
m[p.Sha] = p
596
547
}
597
548
598
-
identsToResolve := make([]string, len(pulls))
599
-
for i, pull := range pulls {
600
-
identsToResolve[i] = pull.OwnerDid
601
-
}
602
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
603
-
didHandleMap := make(map[string]string)
604
-
for _, identity := range resolvedIds {
605
-
if !identity.Handle.IsInvalidHandle() {
606
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
607
-
} else {
608
-
didHandleMap[identity.DID.String()] = identity.DID.String()
609
-
}
610
-
}
611
-
612
549
s.pages.RepoPulls(w, pages.RepoPullsParams{
613
550
LoggedInUser: s.oauth.GetUser(r),
614
551
RepoInfo: f.RepoInfo(user),
615
552
Pulls: pulls,
616
-
DidHandleMap: didHandleMap,
617
553
FilteringBy: state,
618
554
Stacks: stacks,
619
555
Pipelines: m,
···
669
605
defer tx.Rollback()
670
606
671
607
createdAt := time.Now().Format(time.RFC3339)
672
-
ownerDid := user.Did
673
608
674
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
609
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
675
610
if err != nil {
676
611
log.Println("failed to get pull at", err)
677
612
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
678
613
return
679
614
}
680
615
681
-
atUri := f.RepoAt.String()
682
616
client, err := s.oauth.AuthorizedClient(r)
683
617
if err != nil {
684
618
log.Println("failed to get authorized client", err)
···
691
625
Rkey: tid.TID(),
692
626
Record: &lexutil.LexiconTypeDecoder{
693
627
Val: &tangled.RepoPullComment{
694
-
Repo: &atUri,
695
628
Pull: string(pullAt),
696
-
Owner: &ownerDid,
697
629
Body: body,
698
630
CreatedAt: createdAt,
699
631
},
···
707
639
708
640
comment := &db.PullComment{
709
641
OwnerDid: user.Did,
710
-
RepoAt: f.RepoAt.String(),
642
+
RepoAt: f.RepoAt().String(),
711
643
PullId: pull.PullId,
712
644
Body: body,
713
645
CommentAt: atResp.Uri,
···
753
685
return
754
686
}
755
687
756
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
688
+
result, err := us.Branches(f.OwnerDid(), f.Name)
757
689
if err != nil {
758
690
log.Println("failed to fetch branches", err)
759
691
return
···
801
733
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
802
734
return
803
735
}
736
+
sanitizer := markup.NewSanitizer()
737
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
738
+
s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
739
+
return
740
+
}
804
741
}
805
742
806
743
// Validate we have at least one valid PR creation method
···
877
814
return
878
815
}
879
816
880
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
817
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
881
818
if err != nil {
882
819
log.Println("failed to compare", err)
883
820
s.pages.Notice(w, "pull", err.Error())
···
913
850
}
914
851
915
852
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
916
-
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
853
+
repoString := strings.SplitN(forkRepo, "/", 2)
854
+
forkOwnerDid := repoString[0]
855
+
repoName := repoString[1]
856
+
fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName)
917
857
if errors.Is(err, sql.ErrNoRows) {
918
858
s.pages.Notice(w, "pull", "No such fork.")
919
859
return
···
923
863
return
924
864
}
925
865
926
-
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
927
-
if err != nil {
928
-
log.Println("failed to fetch registration key:", err)
929
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
930
-
return
931
-
}
932
-
933
-
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
866
+
client, err := s.oauth.ServiceClient(
867
+
r,
868
+
oauth.WithService(fork.Knot),
869
+
oauth.WithLxm(tangled.RepoHiddenRefNSID),
870
+
oauth.WithDev(s.config.Core.Dev),
871
+
)
934
872
if err != nil {
935
-
log.Println("failed to create signed client:", err)
873
+
log.Printf("failed to connect to knot server: %v", err)
936
874
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
937
875
return
938
876
}
···
944
882
return
945
883
}
946
884
947
-
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
948
-
if err != nil {
949
-
log.Println("failed to create hidden ref:", err, resp.StatusCode)
950
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
885
+
resp, err := tangled.RepoHiddenRef(
886
+
r.Context(),
887
+
client,
888
+
&tangled.RepoHiddenRef_Input{
889
+
ForkRef: sourceBranch,
890
+
RemoteRef: targetBranch,
891
+
Repo: fork.RepoAt().String(),
892
+
},
893
+
)
894
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
895
+
s.pages.Notice(w, "pull", err.Error())
951
896
return
952
897
}
953
898
954
-
switch resp.StatusCode {
955
-
case 404:
956
-
case 400:
957
-
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
899
+
if !resp.Success {
900
+
errorMsg := "Failed to create pull request"
901
+
if resp.Error != nil {
902
+
errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error)
903
+
}
904
+
s.pages.Notice(w, "pull", errorMsg)
958
905
return
959
906
}
960
907
···
964
911
// hiddenRef: hidden/feature-1/main (on repo-fork)
965
912
// targetBranch: main (on repo-1)
966
913
// sourceBranch: feature-1 (on repo-fork)
967
-
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
914
+
comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch)
968
915
if err != nil {
969
916
log.Println("failed to compare across branches", err)
970
917
s.pages.Notice(w, "pull", err.Error())
···
979
926
return
980
927
}
981
928
982
-
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
983
-
if err != nil {
984
-
log.Println("failed to parse fork AT URI", err)
985
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
986
-
return
987
-
}
929
+
forkAtUri := fork.RepoAt()
930
+
forkAtUriStr := forkAtUri.String()
988
931
989
932
pullSource := &db.PullSource{
990
933
Branch: sourceBranch,
···
992
935
}
993
936
recordPullSource := &tangled.RepoPull_Source{
994
937
Branch: sourceBranch,
995
-
Repo: &fork.AtUri,
938
+
Repo: &forkAtUriStr,
996
939
Sha: sourceRev,
997
940
}
998
941
···
1068
1011
Body: body,
1069
1012
TargetBranch: targetBranch,
1070
1013
OwnerDid: user.Did,
1071
-
RepoAt: f.RepoAt,
1014
+
RepoAt: f.RepoAt(),
1072
1015
Rkey: rkey,
1073
1016
Submissions: []*db.PullSubmission{
1074
1017
&initialSubmission,
···
1081
1024
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1082
1025
return
1083
1026
}
1084
-
pullId, err := db.NextPullId(tx, f.RepoAt)
1027
+
pullId, err := db.NextPullId(tx, f.RepoAt())
1085
1028
if err != nil {
1086
1029
log.Println("failed to get pull id", err)
1087
1030
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1094
1037
Rkey: rkey,
1095
1038
Record: &lexutil.LexiconTypeDecoder{
1096
1039
Val: &tangled.RepoPull{
1097
-
Title: title,
1098
-
PullId: int64(pullId),
1099
-
TargetRepo: string(f.RepoAt),
1100
-
TargetBranch: targetBranch,
1101
-
Patch: patch,
1102
-
Source: recordPullSource,
1040
+
Title: title,
1041
+
Target: &tangled.RepoPull_Target{
1042
+
Repo: string(f.RepoAt()),
1043
+
Branch: targetBranch,
1044
+
},
1045
+
Patch: patch,
1046
+
Source: recordPullSource,
1103
1047
},
1104
1048
},
1105
1049
})
···
1274
1218
return
1275
1219
}
1276
1220
1277
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1221
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1278
1222
if err != nil {
1279
1223
log.Println("failed to reach knotserver", err)
1280
1224
return
···
1330
1274
}
1331
1275
1332
1276
forkVal := r.URL.Query().Get("fork")
1333
-
1277
+
repoString := strings.SplitN(forkVal, "/", 2)
1278
+
forkOwnerDid := repoString[0]
1279
+
forkName := repoString[1]
1334
1280
// fork repo
1335
-
repo, err := db.GetRepo(s.db, user.Did, forkVal)
1281
+
repo, err := db.GetRepo(s.db, forkOwnerDid, forkName)
1336
1282
if err != nil {
1337
1283
log.Println("failed to get repo", user.Did, forkVal)
1338
1284
return
···
1345
1291
return
1346
1292
}
1347
1293
1348
-
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1294
+
sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name)
1349
1295
if err != nil {
1350
1296
log.Println("failed to reach knotserver for source branches", err)
1351
1297
return
···
1358
1304
return
1359
1305
}
1360
1306
1361
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1307
+
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1362
1308
if err != nil {
1363
1309
log.Println("failed to reach knotserver for target branches", err)
1364
1310
return
···
1474
1420
return
1475
1421
}
1476
1422
1477
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1423
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1478
1424
if err != nil {
1479
1425
log.Printf("compare request failed: %s", err)
1480
1426
s.pages.Notice(w, "resubmit-error", err.Error())
···
1524
1470
return
1525
1471
}
1526
1472
1527
-
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1473
+
// update the hidden tracking branch to latest
1474
+
client, err := s.oauth.ServiceClient(
1475
+
r,
1476
+
oauth.WithService(forkRepo.Knot),
1477
+
oauth.WithLxm(tangled.RepoHiddenRefNSID),
1478
+
oauth.WithDev(s.config.Core.Dev),
1479
+
)
1528
1480
if err != nil {
1529
-
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1530
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1481
+
log.Printf("failed to connect to knot server: %v", err)
1531
1482
return
1532
1483
}
1533
1484
1534
-
// update the hidden tracking branch to latest
1535
-
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1536
-
if err != nil {
1537
-
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1538
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1485
+
resp, err := tangled.RepoHiddenRef(
1486
+
r.Context(),
1487
+
client,
1488
+
&tangled.RepoHiddenRef_Input{
1489
+
ForkRef: pull.PullSource.Branch,
1490
+
RemoteRef: pull.TargetBranch,
1491
+
Repo: forkRepo.RepoAt().String(),
1492
+
},
1493
+
)
1494
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1495
+
s.pages.Notice(w, "resubmit-error", err.Error())
1539
1496
return
1540
1497
}
1541
-
1542
-
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1543
-
if err != nil || resp.StatusCode != http.StatusNoContent {
1544
-
log.Printf("failed to update tracking branch: %s", err)
1545
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1498
+
if !resp.Success {
1499
+
log.Println("Failed to update tracking ref.", "err", resp.Error)
1500
+
s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1546
1501
return
1547
1502
}
1548
1503
···
1656
1611
SwapRecord: ex.Cid,
1657
1612
Record: &lexutil.LexiconTypeDecoder{
1658
1613
Val: &tangled.RepoPull{
1659
-
Title: pull.Title,
1660
-
PullId: int64(pull.PullId),
1661
-
TargetRepo: string(f.RepoAt),
1662
-
TargetBranch: pull.TargetBranch,
1663
-
Patch: patch, // new patch
1664
-
Source: recordPullSource,
1614
+
Title: pull.Title,
1615
+
Target: &tangled.RepoPull_Target{
1616
+
Repo: string(f.RepoAt()),
1617
+
Branch: pull.TargetBranch,
1618
+
},
1619
+
Patch: patch, // new patch
1620
+
Source: recordPullSource,
1665
1621
},
1666
1622
},
1667
1623
})
···
1774
1730
1775
1731
// deleted pulls are marked as deleted in the DB
1776
1732
for _, p := range deletions {
1733
+
// do not do delete already merged PRs
1734
+
if p.State == db.PullMerged {
1735
+
continue
1736
+
}
1737
+
1777
1738
err := db.DeletePull(tx, p.RepoAt, p.PullId)
1778
1739
if err != nil {
1779
1740
log.Println("failed to delete pull", err, p.PullId)
···
1814
1775
op, _ := origById[id]
1815
1776
np, _ := newById[id]
1816
1777
1778
+
// do not update already merged PRs
1779
+
if op.State == db.PullMerged {
1780
+
continue
1781
+
}
1782
+
1817
1783
submission := np.Submissions[np.LastRoundNumber()]
1818
1784
1819
1785
// resubmit the old pull
···
1958
1924
1959
1925
patch := pullsToMerge.CombinedPatch()
1960
1926
1961
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
1962
-
if err != nil {
1963
-
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1964
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1965
-
return
1966
-
}
1967
-
1968
1927
ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1969
1928
if err != nil {
1970
1929
log.Printf("resolving identity: %s", err)
···
1977
1936
log.Printf("failed to get primary email: %s", err)
1978
1937
}
1979
1938
1980
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1981
-
if err != nil {
1982
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1983
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1984
-
return
1939
+
authorName := ident.Handle.String()
1940
+
mergeInput := &tangled.RepoMerge_Input{
1941
+
Did: f.OwnerDid(),
1942
+
Name: f.Name,
1943
+
Branch: pull.TargetBranch,
1944
+
Patch: patch,
1945
+
CommitMessage: &pull.Title,
1946
+
AuthorName: &authorName,
1947
+
}
1948
+
1949
+
if pull.Body != "" {
1950
+
mergeInput.CommitBody = &pull.Body
1951
+
}
1952
+
1953
+
if email.Address != "" {
1954
+
mergeInput.AuthorEmail = &email.Address
1985
1955
}
1986
1956
1987
-
// Merge the pull request
1988
-
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1957
+
client, err := s.oauth.ServiceClient(
1958
+
r,
1959
+
oauth.WithService(f.Knot),
1960
+
oauth.WithLxm(tangled.RepoMergeNSID),
1961
+
oauth.WithDev(s.config.Core.Dev),
1962
+
)
1989
1963
if err != nil {
1990
-
log.Printf("failed to merge pull request: %s", err)
1964
+
log.Printf("failed to connect to knot server: %v", err)
1991
1965
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1992
1966
return
1993
1967
}
1994
1968
1995
-
if resp.StatusCode != http.StatusOK {
1996
-
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1997
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1969
+
err = tangled.RepoMerge(r.Context(), client, mergeInput)
1970
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1971
+
s.pages.Notice(w, "pull-merge-error", err.Error())
1998
1972
return
1999
1973
}
2000
1974
···
2007
1981
defer tx.Rollback()
2008
1982
2009
1983
for _, p := range pullsToMerge {
2010
-
err := db.MergePull(tx, f.RepoAt, p.PullId)
1984
+
err := db.MergePull(tx, f.RepoAt(), p.PullId)
2011
1985
if err != nil {
2012
1986
log.Printf("failed to update pull request status in database: %s", err)
2013
1987
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
2023
1997
return
2024
1998
}
2025
1999
2026
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
2000
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2027
2001
}
2028
2002
2029
2003
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2075
2049
2076
2050
for _, p := range pullsToClose {
2077
2051
// Close the pull in the database
2078
-
err = db.ClosePull(tx, f.RepoAt, p.PullId)
2052
+
err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2079
2053
if err != nil {
2080
2054
log.Println("failed to close pull", err)
2081
2055
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2143
2117
2144
2118
for _, p := range pullsToReopen {
2145
2119
// Close the pull in the database
2146
-
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2120
+
err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2147
2121
if err != nil {
2148
2122
log.Println("failed to close pull", err)
2149
2123
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2195
2169
Body: body,
2196
2170
TargetBranch: targetBranch,
2197
2171
OwnerDid: user.Did,
2198
-
RepoAt: f.RepoAt,
2172
+
RepoAt: f.RepoAt(),
2199
2173
Rkey: rkey,
2200
2174
Submissions: []*db.PullSubmission{
2201
2175
&initialSubmission,
+6
-6
appview/repo/artifact.go
+6
-6
appview/repo/artifact.go
···
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
+
}
+17
-104
appview/repo/index.go
+17
-104
appview/repo/index.go
···
1
1
package repo
2
2
3
3
import (
4
-
"encoding/json"
5
-
"fmt"
6
4
"log"
7
5
"net/http"
8
6
"slices"
···
11
9
12
10
"tangled.sh/tangled.sh/core/appview/commitverify"
13
11
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/oauth"
15
12
"tangled.sh/tangled.sh/core/appview/pages"
16
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
17
13
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
14
"tangled.sh/tangled.sh/core/knotclient"
19
15
"tangled.sh/tangled.sh/core/types"
···
24
20
25
21
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
26
22
ref := chi.URLParam(r, "ref")
23
+
27
24
f, err := rp.repoResolver.Resolve(r)
28
25
if err != nil {
29
26
log.Println("failed to fully resolve repo", err)
···
37
34
return
38
35
}
39
36
40
-
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
37
+
result, err := us.Index(f.OwnerDid(), f.Name, ref)
41
38
if err != nil {
42
39
rp.pages.Error503(w)
43
40
log.Println("failed to reach knotserver", err)
···
104
101
user := rp.oauth.GetUser(r)
105
102
repoInfo := f.RepoInfo(user)
106
103
107
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
108
-
if err != nil {
109
-
log.Printf("failed to get registration key for %s: %s", f.Knot, err)
110
-
rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
111
-
}
112
-
113
-
signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
114
-
if err != nil {
115
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
116
-
return
117
-
}
118
-
119
-
var forkInfo *types.ForkInfo
120
-
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
121
-
forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
122
-
if err != nil {
123
-
log.Printf("Failed to fetch fork information: %v", err)
124
-
return
125
-
}
126
-
}
127
-
128
104
// TODO: a bit dirty
129
-
languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "")
105
+
languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "")
130
106
if err != nil {
131
107
log.Printf("failed to compute language percentages: %s", err)
132
108
// non-fatal
···
143
119
}
144
120
145
121
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
146
-
LoggedInUser: user,
147
-
RepoInfo: repoInfo,
148
-
TagMap: tagMap,
149
-
RepoIndexResponse: *result,
150
-
CommitsTrunc: commitsTrunc,
151
-
TagsTrunc: tagsTrunc,
152
-
ForkInfo: forkInfo,
122
+
LoggedInUser: user,
123
+
RepoInfo: repoInfo,
124
+
TagMap: tagMap,
125
+
RepoIndexResponse: *result,
126
+
CommitsTrunc: commitsTrunc,
127
+
TagsTrunc: tagsTrunc,
128
+
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
153
129
BranchesTrunc: branchesTrunc,
154
130
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
155
131
VerifiedCommits: vc,
···
160
136
161
137
func (rp *Repo) getLanguageInfo(
162
138
f *reporesolver.ResolvedRepo,
163
-
signedClient *knotclient.SignedClient,
139
+
us *knotclient.UnsignedClient,
140
+
currentRef string,
164
141
isDefaultRef bool,
165
142
) ([]types.RepoLanguageDetails, error) {
166
143
// first attempt to fetch from db
167
144
langs, err := db.GetRepoLanguages(
168
145
rp.db,
169
-
db.FilterEq("repo_at", f.RepoAt),
170
-
db.FilterEq("ref", f.Ref),
146
+
db.FilterEq("repo_at", f.RepoAt()),
147
+
db.FilterEq("ref", currentRef),
171
148
)
172
149
173
150
if err != nil || langs == nil {
174
151
// non-fatal, fetch langs from ks
175
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref)
152
+
ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
176
153
if err != nil {
177
154
return nil, err
178
155
}
···
182
159
183
160
for l, s := range ls.Languages {
184
161
langs = append(langs, db.RepoLanguage{
185
-
RepoAt: f.RepoAt,
186
-
Ref: f.Ref,
162
+
RepoAt: f.RepoAt(),
163
+
Ref: currentRef,
187
164
IsDefaultRef: isDefaultRef,
188
165
Language: l,
189
166
Bytes: s,
···
229
206
230
207
return languageStats, nil
231
208
}
232
-
233
-
func getForkInfo(
234
-
repoInfo repoinfo.RepoInfo,
235
-
rp *Repo,
236
-
f *reporesolver.ResolvedRepo,
237
-
user *oauth.User,
238
-
signedClient *knotclient.SignedClient,
239
-
) (*types.ForkInfo, error) {
240
-
if user == nil {
241
-
return nil, nil
242
-
}
243
-
244
-
forkInfo := types.ForkInfo{
245
-
IsFork: repoInfo.Source != nil,
246
-
Status: types.UpToDate,
247
-
}
248
-
249
-
if !forkInfo.IsFork {
250
-
forkInfo.IsFork = false
251
-
return &forkInfo, nil
252
-
}
253
-
254
-
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
255
-
if err != nil {
256
-
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
257
-
return nil, err
258
-
}
259
-
260
-
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
261
-
if err != nil {
262
-
log.Println("failed to reach knotserver", err)
263
-
return nil, err
264
-
}
265
-
266
-
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
267
-
return branch.Name == f.Ref
268
-
}) {
269
-
forkInfo.Status = types.MissingBranch
270
-
return &forkInfo, nil
271
-
}
272
-
273
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
274
-
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
275
-
log.Printf("failed to update tracking branch: %s", err)
276
-
return nil, err
277
-
}
278
-
279
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
280
-
281
-
var status types.AncestorCheckResponse
282
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
283
-
if err != nil {
284
-
log.Printf("failed to check if fork is ahead/behind: %s", err)
285
-
return nil, err
286
-
}
287
-
288
-
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
289
-
log.Printf("failed to decode fork status: %s", err)
290
-
return nil, err
291
-
}
292
-
293
-
forkInfo.Status = status.Status
294
-
return &forkInfo, nil
295
-
}
+327
-258
appview/repo/repo.go
+327
-258
appview/repo/repo.go
···
17
17
"strings"
18
18
"time"
19
19
20
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
+
lexutil "github.com/bluesky-social/indigo/lex/util"
20
22
"tangled.sh/tangled.sh/core/api/tangled"
21
23
"tangled.sh/tangled.sh/core/appview/commitverify"
22
24
"tangled.sh/tangled.sh/core/appview/config"
···
26
28
"tangled.sh/tangled.sh/core/appview/pages"
27
29
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
29
32
"tangled.sh/tangled.sh/core/eventconsumer"
30
33
"tangled.sh/tangled.sh/core/idresolver"
31
34
"tangled.sh/tangled.sh/core/knotclient"
···
33
36
"tangled.sh/tangled.sh/core/rbac"
34
37
"tangled.sh/tangled.sh/core/tid"
35
38
"tangled.sh/tangled.sh/core/types"
39
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
36
40
37
41
securejoin "github.com/cyphar/filepath-securejoin"
38
42
"github.com/go-chi/chi/v5"
39
43
"github.com/go-git/go-git/v5/plumbing"
40
44
41
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
45
"github.com/bluesky-social/indigo/atproto/syntax"
43
-
lexutil "github.com/bluesky-social/indigo/lex/util"
44
46
)
45
47
46
48
type Repo struct {
···
54
56
enforcer *rbac.Enforcer
55
57
notifier notify.Notifier
56
58
logger *slog.Logger
59
+
serviceAuth *serviceauth.ServiceAuth
57
60
}
58
61
59
62
func New(
···
81
84
}
82
85
}
83
86
87
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
88
+
refParam := chi.URLParam(r, "ref")
89
+
f, err := rp.repoResolver.Resolve(r)
90
+
if err != nil {
91
+
log.Println("failed to get repo and knot", err)
92
+
return
93
+
}
94
+
95
+
var uri string
96
+
if rp.config.Core.Dev {
97
+
uri = "http"
98
+
} else {
99
+
uri = "https"
100
+
}
101
+
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
102
+
103
+
http.Redirect(w, r, url, http.StatusFound)
104
+
}
105
+
84
106
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
85
107
f, err := rp.repoResolver.Resolve(r)
86
108
if err != nil {
···
104
126
return
105
127
}
106
128
107
-
repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
129
+
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
108
130
if err != nil {
131
+
rp.pages.Error503(w)
109
132
log.Println("failed to reach knotserver", err)
110
133
return
111
134
}
112
135
113
-
tagResult, err := us.Tags(f.OwnerDid(), f.RepoName)
136
+
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
114
137
if err != nil {
138
+
rp.pages.Error503(w)
115
139
log.Println("failed to reach knotserver", err)
116
140
return
117
141
}
···
125
149
tagMap[hash] = append(tagMap[hash], tag.Name)
126
150
}
127
151
128
-
branchResult, err := us.Branches(f.OwnerDid(), f.RepoName)
152
+
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
129
153
if err != nil {
154
+
rp.pages.Error503(w)
130
155
log.Println("failed to reach knotserver", err)
131
156
return
132
157
}
···
193
218
return
194
219
}
195
220
196
-
repoAt := f.RepoAt
221
+
repoAt := f.RepoAt()
197
222
rkey := repoAt.RecordKey().String()
198
223
if rkey == "" {
199
224
log.Println("invalid aturi for repo", err)
···
243
268
Record: &lexutil.LexiconTypeDecoder{
244
269
Val: &tangled.Repo{
245
270
Knot: f.Knot,
246
-
Name: f.RepoName,
271
+
Name: f.Name,
247
272
Owner: user.Did,
248
-
CreatedAt: f.CreatedAt,
273
+
CreatedAt: f.Created.Format(time.RFC3339),
249
274
Description: &newDescription,
250
275
Spindle: &f.Spindle,
251
276
},
···
291
316
return
292
317
}
293
318
294
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
319
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
295
320
if err != nil {
321
+
rp.pages.Error503(w)
296
322
log.Println("failed to reach knotserver", err)
297
323
return
298
324
}
···
356
382
if !rp.config.Core.Dev {
357
383
protocol = "https"
358
384
}
359
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
385
+
386
+
// if the tree path has a trailing slash, let's strip it
387
+
// so we don't 404
388
+
treePath = strings.TrimSuffix(treePath, "/")
389
+
390
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
360
391
if err != nil {
392
+
rp.pages.Error503(w)
361
393
log.Println("failed to reach knotserver", err)
362
394
return
363
395
}
364
396
397
+
// uhhh so knotserver returns a 500 if the entry isn't found in
398
+
// the requested tree path, so let's stick to not-OK here.
399
+
// we can fix this once we build out the xrpc apis for these operations.
400
+
if resp.StatusCode != http.StatusOK {
401
+
rp.pages.Error404(w)
402
+
return
403
+
}
404
+
365
405
body, err := io.ReadAll(resp.Body)
366
406
if err != nil {
367
407
log.Printf("Error reading response body: %v", err)
···
386
426
user := rp.oauth.GetUser(r)
387
427
388
428
var breadcrumbs [][]string
389
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
429
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
390
430
if treePath != "" {
391
431
for idx, elem := range strings.Split(treePath, "/") {
392
432
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
417
457
return
418
458
}
419
459
420
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
460
+
result, err := us.Tags(f.OwnerDid(), f.Name)
421
461
if err != nil {
462
+
rp.pages.Error503(w)
422
463
log.Println("failed to reach knotserver", err)
423
464
return
424
465
}
425
466
426
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt))
467
+
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
427
468
if err != nil {
428
469
log.Println("failed grab artifacts", err)
429
470
return
···
474
515
return
475
516
}
476
517
477
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
518
+
result, err := us.Branches(f.OwnerDid(), f.Name)
478
519
if err != nil {
520
+
rp.pages.Error503(w)
479
521
log.Println("failed to reach knotserver", err)
480
522
return
481
523
}
···
503
545
if !rp.config.Core.Dev {
504
546
protocol = "https"
505
547
}
506
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
548
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
507
549
if err != nil {
550
+
rp.pages.Error503(w)
508
551
log.Println("failed to reach knotserver", err)
509
552
return
510
553
}
511
554
555
+
if resp.StatusCode == http.StatusNotFound {
556
+
rp.pages.Error404(w)
557
+
return
558
+
}
559
+
512
560
body, err := io.ReadAll(resp.Body)
513
561
if err != nil {
514
562
log.Printf("Error reading response body: %v", err)
···
523
571
}
524
572
525
573
var breadcrumbs [][]string
526
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
574
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
527
575
if filePath != "" {
528
576
for idx, elem := range strings.Split(filePath, "/") {
529
577
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
556
604
557
605
// fetch the actual binary content like in RepoBlobRaw
558
606
559
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
607
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
560
608
contentSrc = blobURL
561
609
if !rp.config.Core.Dev {
562
610
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
···
593
641
if !rp.config.Core.Dev {
594
642
protocol = "https"
595
643
}
596
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
597
-
resp, err := http.Get(blobURL)
644
+
645
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
646
+
647
+
req, err := http.NewRequest("GET", blobURL, nil)
648
+
if err != nil {
649
+
log.Println("failed to create request", err)
650
+
return
651
+
}
652
+
653
+
// forward the If-None-Match header
654
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
655
+
req.Header.Set("If-None-Match", clientETag)
656
+
}
657
+
658
+
client := &http.Client{}
659
+
resp, err := client.Do(req)
598
660
if err != nil {
599
-
log.Println("failed to reach knotserver:", err)
661
+
log.Println("failed to reach knotserver", err)
600
662
rp.pages.Error503(w)
601
663
return
602
664
}
603
665
defer resp.Body.Close()
666
+
667
+
// forward 304 not modified
668
+
if resp.StatusCode == http.StatusNotModified {
669
+
w.WriteHeader(http.StatusNotModified)
670
+
return
671
+
}
604
672
605
673
if resp.StatusCode != http.StatusOK {
606
674
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
···
649
717
return
650
718
}
651
719
652
-
repoAt := f.RepoAt
720
+
repoAt := f.RepoAt()
653
721
rkey := repoAt.RecordKey().String()
654
722
if rkey == "" {
655
723
fail("Failed to resolve repo. Try again later", err)
···
657
725
}
658
726
659
727
newSpindle := r.FormValue("spindle")
728
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
660
729
client, err := rp.oauth.AuthorizedClient(r)
661
730
if err != nil {
662
731
fail("Failed to authorize. Try again later.", err)
663
732
return
664
733
}
665
734
666
-
// ensure that this is a valid spindle for this user
667
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
668
-
if err != nil {
669
-
fail("Failed to find spindles. Try again later.", err)
670
-
return
735
+
if !removingSpindle {
736
+
// ensure that this is a valid spindle for this user
737
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
738
+
if err != nil {
739
+
fail("Failed to find spindles. Try again later.", err)
740
+
return
741
+
}
742
+
743
+
if !slices.Contains(validSpindles, newSpindle) {
744
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
745
+
return
746
+
}
671
747
}
672
748
673
-
if !slices.Contains(validSpindles, newSpindle) {
674
-
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
675
-
return
749
+
spindlePtr := &newSpindle
750
+
if removingSpindle {
751
+
spindlePtr = nil
676
752
}
677
753
678
754
// optimistic update
679
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
755
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
680
756
if err != nil {
681
757
fail("Failed to update spindle. Try again later.", err)
682
758
return
···
695
771
Record: &lexutil.LexiconTypeDecoder{
696
772
Val: &tangled.Repo{
697
773
Knot: f.Knot,
698
-
Name: f.RepoName,
774
+
Name: f.Name,
699
775
Owner: user.Did,
700
-
CreatedAt: f.CreatedAt,
776
+
CreatedAt: f.Created.Format(time.RFC3339),
701
777
Description: &f.Description,
702
-
Spindle: &newSpindle,
778
+
Spindle: spindlePtr,
703
779
},
704
780
},
705
781
})
···
709
785
return
710
786
}
711
787
712
-
// add this spindle to spindle stream
713
-
rp.spindlestream.AddSource(
714
-
context.Background(),
715
-
eventconsumer.NewSpindleSource(newSpindle),
716
-
)
788
+
if !removingSpindle {
789
+
// add this spindle to spindle stream
790
+
rp.spindlestream.AddSource(
791
+
context.Background(),
792
+
eventconsumer.NewSpindleSource(newSpindle),
793
+
)
794
+
}
717
795
718
796
rp.pages.HxRefresh(w)
719
797
}
···
776
854
Record: &lexutil.LexiconTypeDecoder{
777
855
Val: &tangled.RepoCollaborator{
778
856
Subject: collaboratorIdent.DID.String(),
779
-
Repo: string(f.RepoAt),
857
+
Repo: string(f.RepoAt()),
780
858
CreatedAt: createdAt.Format(time.RFC3339),
781
859
}},
782
860
})
···
785
863
fail("Failed to write record to PDS.", err)
786
864
return
787
865
}
788
-
l = l.With("at-uri", resp.Uri)
866
+
867
+
aturi := resp.Uri
868
+
l = l.With("at-uri", aturi)
789
869
l.Info("wrote record to PDS")
790
870
791
-
l.Info("adding to knot")
792
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
871
+
tx, err := rp.db.BeginTx(r.Context(), nil)
793
872
if err != nil {
794
-
fail("Failed to add to knot.", err)
873
+
fail("Failed to add collaborator.", err)
795
874
return
796
875
}
797
876
798
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
799
-
if err != nil {
800
-
fail("Failed to add to knot.", err)
801
-
return
802
-
}
877
+
rollback := func() {
878
+
err1 := tx.Rollback()
879
+
err2 := rp.enforcer.E.LoadPolicy()
880
+
err3 := rollbackRecord(context.Background(), aturi, client)
803
881
804
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
805
-
if err != nil {
806
-
fail("Knot was unreachable.", err)
807
-
return
808
-
}
882
+
// ignore txn complete errors, this is okay
883
+
if errors.Is(err1, sql.ErrTxDone) {
884
+
err1 = nil
885
+
}
809
886
810
-
if ksResp.StatusCode != http.StatusNoContent {
811
-
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
812
-
return
813
-
}
814
-
815
-
tx, err := rp.db.BeginTx(r.Context(), nil)
816
-
if err != nil {
817
-
fail("Failed to add collaborator.", err)
818
-
return
819
-
}
820
-
defer func() {
821
-
tx.Rollback()
822
-
err = rp.enforcer.E.LoadPolicy()
823
-
if err != nil {
824
-
fail("Failed to add collaborator.", err)
887
+
if errs := errors.Join(err1, err2, err3); errs != nil {
888
+
l.Error("failed to rollback changes", "errs", errs)
889
+
return
825
890
}
826
-
}()
891
+
}
892
+
defer rollback()
827
893
828
894
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
829
895
if err != nil {
···
835
901
Did: syntax.DID(currentUser.Did),
836
902
Rkey: rkey,
837
903
SubjectDid: collaboratorIdent.DID,
838
-
RepoAt: f.RepoAt,
904
+
RepoAt: f.RepoAt(),
839
905
Created: createdAt,
840
906
})
841
907
if err != nil {
···
855
921
return
856
922
}
857
923
924
+
// clear aturi to when everything is successful
925
+
aturi = ""
926
+
858
927
rp.pages.HxRefresh(w)
859
928
}
860
929
861
930
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
862
931
user := rp.oauth.GetUser(r)
863
932
933
+
noticeId := "operation-error"
864
934
f, err := rp.repoResolver.Resolve(r)
865
935
if err != nil {
866
936
log.Println("failed to get repo and knot", err)
···
873
943
log.Println("failed to get authorized client", err)
874
944
return
875
945
}
876
-
repoRkey := f.RepoAt.RecordKey().String()
877
946
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
878
947
Collection: tangled.RepoNSID,
879
948
Repo: user.Did,
880
-
Rkey: repoRkey,
949
+
Rkey: f.Rkey,
881
950
})
882
951
if err != nil {
883
952
log.Printf("failed to delete record: %s", err)
884
-
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
885
-
return
886
-
}
887
-
log.Println("removed repo record ", f.RepoAt.String())
888
-
889
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
890
-
if err != nil {
891
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
953
+
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
892
954
return
893
955
}
956
+
log.Println("removed repo record ", f.RepoAt().String())
894
957
895
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
958
+
client, err := rp.oauth.ServiceClient(
959
+
r,
960
+
oauth.WithService(f.Knot),
961
+
oauth.WithLxm(tangled.RepoDeleteNSID),
962
+
oauth.WithDev(rp.config.Core.Dev),
963
+
)
896
964
if err != nil {
897
-
log.Println("failed to create client to ", f.Knot)
965
+
log.Println("failed to connect to knot server:", err)
898
966
return
899
967
}
900
968
901
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
902
-
if err != nil {
903
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
969
+
err = tangled.RepoDelete(
970
+
r.Context(),
971
+
client,
972
+
&tangled.RepoDelete_Input{
973
+
Did: f.OwnerDid(),
974
+
Name: f.Name,
975
+
Rkey: f.Rkey,
976
+
},
977
+
)
978
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
979
+
rp.pages.Notice(w, noticeId, err.Error())
904
980
return
905
981
}
906
-
907
-
if ksResp.StatusCode != http.StatusNoContent {
908
-
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
909
-
} else {
910
-
log.Println("removed repo from knot ", f.Knot)
911
-
}
982
+
log.Println("deleted repo from knot")
912
983
913
984
tx, err := rp.db.BeginTx(r.Context(), nil)
914
985
if err != nil {
···
927
998
// remove collaborator RBAC
928
999
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
929
1000
if err != nil {
930
-
rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1001
+
rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
931
1002
return
932
1003
}
933
1004
for _, c := range repoCollaborators {
···
939
1010
// remove repo RBAC
940
1011
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
941
1012
if err != nil {
942
-
rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1013
+
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
943
1014
return
944
1015
}
945
1016
946
1017
// remove repo from db
947
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
1018
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
948
1019
if err != nil {
949
-
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1020
+
rp.pages.Notice(w, noticeId, "Failed to update appview")
950
1021
return
951
1022
}
952
1023
log.Println("removed repo from db")
···
975
1046
return
976
1047
}
977
1048
1049
+
noticeId := "operation-error"
978
1050
branch := r.FormValue("branch")
979
1051
if branch == "" {
980
1052
http.Error(w, "malformed form", http.StatusBadRequest)
981
1053
return
982
1054
}
983
1055
984
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
985
-
if err != nil {
986
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
987
-
return
988
-
}
989
-
990
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1056
+
client, err := rp.oauth.ServiceClient(
1057
+
r,
1058
+
oauth.WithService(f.Knot),
1059
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
1060
+
oauth.WithDev(rp.config.Core.Dev),
1061
+
)
991
1062
if err != nil {
992
-
log.Println("failed to create client to ", f.Knot)
1063
+
log.Println("failed to connect to knot server:", err)
1064
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
993
1065
return
994
1066
}
995
1067
996
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
997
-
if err != nil {
998
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
1068
+
xe := tangled.RepoSetDefaultBranch(
1069
+
r.Context(),
1070
+
client,
1071
+
&tangled.RepoSetDefaultBranch_Input{
1072
+
Repo: f.RepoAt().String(),
1073
+
DefaultBranch: branch,
1074
+
},
1075
+
)
1076
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
1077
+
log.Println("xrpc failed", "err", xe)
1078
+
rp.pages.Notice(w, noticeId, err.Error())
999
1079
return
1000
1080
}
1001
1081
1002
-
if ksResp.StatusCode != http.StatusNoContent {
1003
-
rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
1004
-
return
1005
-
}
1006
-
1007
-
w.Write(fmt.Append(nil, "default branch set to: ", branch))
1082
+
rp.pages.HxRefresh(w)
1008
1083
}
1009
1084
1010
1085
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
···
1033
1108
r,
1034
1109
oauth.WithService(f.Spindle),
1035
1110
oauth.WithLxm(lxm),
1111
+
oauth.WithExp(60),
1036
1112
oauth.WithDev(rp.config.Core.Dev),
1037
1113
)
1038
1114
if err != nil {
···
1060
1136
r.Context(),
1061
1137
spindleClient,
1062
1138
&tangled.RepoAddSecret_Input{
1063
-
Repo: f.RepoAt.String(),
1139
+
Repo: f.RepoAt().String(),
1064
1140
Key: key,
1065
1141
Value: value,
1066
1142
},
···
1078
1154
r.Context(),
1079
1155
spindleClient,
1080
1156
&tangled.RepoRemoveSecret_Input{
1081
-
Repo: f.RepoAt.String(),
1157
+
Repo: f.RepoAt().String(),
1082
1158
Key: key,
1083
1159
},
1084
1160
)
···
1119
1195
case "pipelines":
1120
1196
rp.pipelineSettings(w, r)
1121
1197
}
1122
-
1123
-
// user := rp.oauth.GetUser(r)
1124
-
// repoCollaborators, err := f.Collaborators(r.Context())
1125
-
// if err != nil {
1126
-
// log.Println("failed to get collaborators", err)
1127
-
// }
1128
-
1129
-
// isCollaboratorInviteAllowed := false
1130
-
// if user != nil {
1131
-
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1132
-
// if err == nil && ok {
1133
-
// isCollaboratorInviteAllowed = true
1134
-
// }
1135
-
// }
1136
-
1137
-
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1138
-
// if err != nil {
1139
-
// log.Println("failed to create unsigned client", err)
1140
-
// return
1141
-
// }
1142
-
1143
-
// result, err := us.Branches(f.OwnerDid(), f.RepoName)
1144
-
// if err != nil {
1145
-
// log.Println("failed to reach knotserver", err)
1146
-
// return
1147
-
// }
1148
-
1149
-
// // all spindles that this user is a member of
1150
-
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1151
-
// if err != nil {
1152
-
// log.Println("failed to fetch spindles", err)
1153
-
// return
1154
-
// }
1155
-
1156
-
// var secrets []*tangled.RepoListSecrets_Secret
1157
-
// if f.Spindle != "" {
1158
-
// if spindleClient, err := rp.oauth.ServiceClient(
1159
-
// r,
1160
-
// oauth.WithService(f.Spindle),
1161
-
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1162
-
// oauth.WithDev(rp.config.Core.Dev),
1163
-
// ); err != nil {
1164
-
// log.Println("failed to create spindle client", err)
1165
-
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1166
-
// log.Println("failed to fetch secrets", err)
1167
-
// } else {
1168
-
// secrets = resp.Secrets
1169
-
// }
1170
-
// }
1171
-
1172
-
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1173
-
// LoggedInUser: user,
1174
-
// RepoInfo: f.RepoInfo(user),
1175
-
// Collaborators: repoCollaborators,
1176
-
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1177
-
// Branches: result.Branches,
1178
-
// Spindles: spindles,
1179
-
// CurrentSpindle: f.Spindle,
1180
-
// Secrets: secrets,
1181
-
// })
1182
1198
}
1183
1199
1184
1200
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
···
1191
1207
return
1192
1208
}
1193
1209
1194
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1210
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1195
1211
if err != nil {
1212
+
rp.pages.Error503(w)
1196
1213
log.Println("failed to reach knotserver", err)
1197
1214
return
1198
1215
}
···
1241
1258
r,
1242
1259
oauth.WithService(f.Spindle),
1243
1260
oauth.WithLxm(tangled.RepoListSecretsNSID),
1261
+
oauth.WithExp(60),
1244
1262
oauth.WithDev(rp.config.Core.Dev),
1245
1263
); err != nil {
1246
1264
log.Println("failed to create spindle client", err)
1247
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1265
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1248
1266
log.Println("failed to fetch secrets", err)
1249
1267
} else {
1250
1268
secrets = resp.Secrets
···
1285
1303
}
1286
1304
1287
1305
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1306
+
ref := chi.URLParam(r, "ref")
1307
+
1288
1308
user := rp.oauth.GetUser(r)
1289
1309
f, err := rp.repoResolver.Resolve(r)
1290
1310
if err != nil {
···
1294
1314
1295
1315
switch r.Method {
1296
1316
case http.MethodPost:
1297
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1317
+
client, err := rp.oauth.ServiceClient(
1318
+
r,
1319
+
oauth.WithService(f.Knot),
1320
+
oauth.WithLxm(tangled.RepoForkSyncNSID),
1321
+
oauth.WithDev(rp.config.Core.Dev),
1322
+
)
1298
1323
if err != nil {
1299
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1324
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1300
1325
return
1301
1326
}
1302
1327
1303
-
client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1304
-
if err != nil {
1305
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1328
+
repoInfo := f.RepoInfo(user)
1329
+
if repoInfo.Source == nil {
1330
+
rp.pages.Notice(w, "repo", "This repository is not a fork.")
1306
1331
return
1307
1332
}
1308
1333
1309
-
var uri string
1310
-
if rp.config.Core.Dev {
1311
-
uri = "http"
1312
-
} else {
1313
-
uri = "https"
1314
-
}
1315
-
forkName := fmt.Sprintf("%s", f.RepoName)
1316
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1317
-
1318
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1319
-
if err != nil {
1320
-
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1334
+
err = tangled.RepoForkSync(
1335
+
r.Context(),
1336
+
client,
1337
+
&tangled.RepoForkSync_Input{
1338
+
Did: user.Did,
1339
+
Name: f.Name,
1340
+
Source: repoInfo.Source.RepoAt().String(),
1341
+
Branch: ref,
1342
+
},
1343
+
)
1344
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1345
+
rp.pages.Notice(w, "repo", err.Error())
1321
1346
return
1322
1347
}
1323
1348
···
1350
1375
})
1351
1376
1352
1377
case http.MethodPost:
1378
+
l := rp.logger.With("handler", "ForkRepo")
1353
1379
1354
-
knot := r.FormValue("knot")
1355
-
if knot == "" {
1380
+
targetKnot := r.FormValue("knot")
1381
+
if targetKnot == "" {
1356
1382
rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1357
1383
return
1358
1384
}
1385
+
l = l.With("targetKnot", targetKnot)
1359
1386
1360
-
ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1387
+
ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
1361
1388
if err != nil || !ok {
1362
1389
rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1363
1390
return
1364
1391
}
1365
1392
1366
-
forkName := fmt.Sprintf("%s", f.RepoName)
1367
-
1393
+
// choose a name for a fork
1394
+
forkName := f.Name
1368
1395
// this check is *only* to see if the forked repo name already exists
1369
1396
// in the user's account.
1370
-
existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1397
+
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1371
1398
if err != nil {
1372
1399
if errors.Is(err, sql.ErrNoRows) {
1373
1400
// no existing repo with this name found, we can use the name as is
···
1380
1407
// repo with this name already exists, append random string
1381
1408
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1382
1409
}
1383
-
secret, err := db.GetRegistrationKey(rp.db, knot)
1384
-
if err != nil {
1385
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1386
-
return
1387
-
}
1410
+
l = l.With("forkName", forkName)
1388
1411
1389
-
client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1390
-
if err != nil {
1391
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1392
-
return
1393
-
}
1394
-
1395
-
var uri string
1412
+
uri := "https"
1396
1413
if rp.config.Core.Dev {
1397
1414
uri = "http"
1398
-
} else {
1399
-
uri = "https"
1400
1415
}
1401
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1402
-
sourceAt := f.RepoAt.String()
1403
1416
1417
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1418
+
l = l.With("cloneUrl", forkSourceUrl)
1419
+
1420
+
sourceAt := f.RepoAt().String()
1421
+
1422
+
// create an atproto record for this fork
1404
1423
rkey := tid.TID()
1405
1424
repo := &db.Repo{
1406
1425
Did: user.Did,
1407
1426
Name: forkName,
1408
-
Knot: knot,
1427
+
Knot: targetKnot,
1409
1428
Rkey: rkey,
1410
1429
Source: sourceAt,
1411
1430
}
1412
1431
1413
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1414
-
if err != nil {
1415
-
log.Println(err)
1416
-
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1417
-
return
1418
-
}
1419
-
defer func() {
1420
-
tx.Rollback()
1421
-
err = rp.enforcer.E.LoadPolicy()
1422
-
if err != nil {
1423
-
log.Println("failed to rollback policies")
1424
-
}
1425
-
}()
1426
-
1427
-
resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1428
-
if err != nil {
1429
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1430
-
return
1431
-
}
1432
-
1433
-
switch resp.StatusCode {
1434
-
case http.StatusConflict:
1435
-
rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1436
-
return
1437
-
case http.StatusInternalServerError:
1438
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1439
-
case http.StatusNoContent:
1440
-
// continue
1441
-
}
1442
-
1443
1432
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1444
1433
if err != nil {
1445
-
log.Println("failed to get authorized client", err)
1446
-
rp.pages.Notice(w, "repo", "Failed to create repository.")
1434
+
l.Error("failed to create xrpcclient", "err", err)
1435
+
rp.pages.Notice(w, "repo", "Failed to fork repository.")
1447
1436
return
1448
1437
}
1449
1438
···
1462
1451
}},
1463
1452
})
1464
1453
if err != nil {
1465
-
log.Printf("failed to create record: %s", err)
1454
+
l.Error("failed to write to PDS", "err", err)
1466
1455
rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1467
1456
return
1468
1457
}
1469
-
log.Println("created repo record: ", atresp.Uri)
1458
+
1459
+
aturi := atresp.Uri
1460
+
l = l.With("aturi", aturi)
1461
+
l.Info("wrote to PDS")
1470
1462
1471
-
repo.AtUri = atresp.Uri
1463
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1464
+
if err != nil {
1465
+
l.Info("txn failed", "err", err)
1466
+
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1467
+
return
1468
+
}
1469
+
1470
+
// The rollback function reverts a few things on failure:
1471
+
// - the pending txn
1472
+
// - the ACLs
1473
+
// - the atproto record created
1474
+
rollback := func() {
1475
+
err1 := tx.Rollback()
1476
+
err2 := rp.enforcer.E.LoadPolicy()
1477
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1478
+
1479
+
// ignore txn complete errors, this is okay
1480
+
if errors.Is(err1, sql.ErrTxDone) {
1481
+
err1 = nil
1482
+
}
1483
+
1484
+
if errs := errors.Join(err1, err2, err3); errs != nil {
1485
+
l.Error("failed to rollback changes", "errs", errs)
1486
+
return
1487
+
}
1488
+
}
1489
+
defer rollback()
1490
+
1491
+
client, err := rp.oauth.ServiceClient(
1492
+
r,
1493
+
oauth.WithService(targetKnot),
1494
+
oauth.WithLxm(tangled.RepoCreateNSID),
1495
+
oauth.WithDev(rp.config.Core.Dev),
1496
+
)
1497
+
if err != nil {
1498
+
l.Error("could not create service client", "err", err)
1499
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1500
+
return
1501
+
}
1502
+
1503
+
err = tangled.RepoCreate(
1504
+
r.Context(),
1505
+
client,
1506
+
&tangled.RepoCreate_Input{
1507
+
Rkey: rkey,
1508
+
Source: &forkSourceUrl,
1509
+
},
1510
+
)
1511
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1512
+
rp.pages.Notice(w, "repo", err.Error())
1513
+
return
1514
+
}
1515
+
1472
1516
err = db.AddRepo(tx, repo)
1473
1517
if err != nil {
1474
1518
log.Println(err)
···
1478
1522
1479
1523
// acls
1480
1524
p, _ := securejoin.SecureJoin(user.Did, forkName)
1481
-
err = rp.enforcer.AddRepo(user.Did, knot, p)
1525
+
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1482
1526
if err != nil {
1483
1527
log.Println(err)
1484
1528
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
···
1499
1543
return
1500
1544
}
1501
1545
1546
+
// reset the ATURI because the transaction completed successfully
1547
+
aturi = ""
1548
+
1549
+
rp.notifier.NewRepo(r.Context(), repo)
1502
1550
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1503
-
return
1504
1551
}
1505
1552
}
1506
1553
1554
+
// this is used to rollback changes made to the PDS
1555
+
//
1556
+
// it is a no-op if the provided ATURI is empty
1557
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1558
+
if aturi == "" {
1559
+
return nil
1560
+
}
1561
+
1562
+
parsed := syntax.ATURI(aturi)
1563
+
1564
+
collection := parsed.Collection().String()
1565
+
repo := parsed.Authority().String()
1566
+
rkey := parsed.RecordKey().String()
1567
+
1568
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1569
+
Collection: collection,
1570
+
Repo: repo,
1571
+
Rkey: rkey,
1572
+
})
1573
+
return err
1574
+
}
1575
+
1507
1576
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1508
1577
user := rp.oauth.GetUser(r)
1509
1578
f, err := rp.repoResolver.Resolve(r)
···
1519
1588
return
1520
1589
}
1521
1590
1522
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1591
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1523
1592
if err != nil {
1524
1593
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1525
1594
log.Println("failed to reach knotserver", err)
···
1549
1618
head = queryHead
1550
1619
}
1551
1620
1552
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1621
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1553
1622
if err != nil {
1554
1623
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1555
1624
log.Println("failed to reach knotserver", err)
···
1611
1680
return
1612
1681
}
1613
1682
1614
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1683
+
branches, err := us.Branches(f.OwnerDid(), f.Name)
1615
1684
if err != nil {
1616
1685
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1617
1686
log.Println("failed to reach knotserver", err)
1618
1687
return
1619
1688
}
1620
1689
1621
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1690
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1622
1691
if err != nil {
1623
1692
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1624
1693
log.Println("failed to reach knotserver", err)
1625
1694
return
1626
1695
}
1627
1696
1628
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1697
+
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1629
1698
if err != nil {
1630
1699
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1631
1700
log.Println("failed to compare", err)
+5
appview/repo/router.go
+5
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))
+37
-104
appview/reporesolver/resolver.go
+37
-104
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"
···
21
20
"tangled.sh/tangled.sh/core/appview/pages"
22
21
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
22
"tangled.sh/tangled.sh/core/idresolver"
24
-
"tangled.sh/tangled.sh/core/knotclient"
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
-
return p
135
-
}
136
-
137
-
func (f *ResolvedRepo) DidSlashRepo() string {
138
-
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
139
89
return p
140
90
}
141
91
···
187
137
// this function is a bit weird since it now returns RepoInfo from an entirely different
188
138
// package. we should refactor this or get rid of RepoInfo entirely.
189
139
func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo {
140
+
repoAt := f.RepoAt()
190
141
isStarred := false
191
142
if user != nil {
192
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt))
143
+
isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt)
193
144
}
194
145
195
-
starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt)
146
+
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
196
147
if err != nil {
197
-
log.Println("failed to get star count for ", f.RepoAt)
148
+
log.Println("failed to get star count for ", repoAt)
198
149
}
199
-
issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt)
150
+
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
200
151
if err != nil {
201
-
log.Println("failed to get issue count for ", f.RepoAt)
152
+
log.Println("failed to get issue count for ", repoAt)
202
153
}
203
-
pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt)
154
+
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
204
155
if err != nil {
205
-
log.Println("failed to get issue count for ", f.RepoAt)
156
+
log.Println("failed to get issue count for ", repoAt)
206
157
}
207
-
source, err := db.GetRepoSource(f.rr.execer, f.RepoAt)
158
+
source, err := db.GetRepoSource(f.rr.execer, repoAt)
208
159
if errors.Is(err, sql.ErrNoRows) {
209
160
source = ""
210
161
} else if err != nil {
211
-
log.Println("failed to get repo source for ", f.RepoAt, err)
162
+
log.Println("failed to get repo source for ", repoAt, err)
212
163
}
213
164
214
165
var sourceRepo *db.Repo
···
228
179
}
229
180
230
181
knot := f.Knot
231
-
var disableFork bool
232
-
us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev)
233
-
if err != nil {
234
-
log.Printf("failed to create unsigned client for %s: %v", knot, err)
235
-
} else {
236
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
237
-
if err != nil {
238
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
239
-
}
240
-
241
-
if len(result.Branches) == 0 {
242
-
disableFork = true
243
-
}
244
-
}
245
182
246
183
repoInfo := repoinfo.RepoInfo{
247
184
OwnerDid: f.OwnerDid(),
248
185
OwnerHandle: f.OwnerHandle(),
249
-
Name: f.RepoName,
250
-
RepoAt: f.RepoAt,
186
+
Name: f.Name,
187
+
RepoAt: repoAt,
251
188
Description: f.Description,
252
-
Ref: f.Ref,
253
189
IsStarred: isStarred,
254
190
Knot: knot,
255
191
Spindle: f.Spindle,
···
259
195
IssueCount: issueCount,
260
196
PullCount: pullCount,
261
197
},
262
-
DisableFork: disableFork,
263
-
CurrentDir: f.CurrentDir,
198
+
CurrentDir: f.CurrentDir,
199
+
Ref: f.Ref,
264
200
}
265
201
266
202
if sourceRepo != nil {
···
284
220
// after the ref. for example:
285
221
//
286
222
// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/
287
-
func extractPathAfterRef(fullPath, ref string) string {
223
+
func extractPathAfterRef(fullPath string) string {
288
224
fullPath = strings.TrimPrefix(fullPath, "/")
289
225
290
-
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)/[^/]+/(.*)$`
291
230
292
-
prefixes := []string{
293
-
fmt.Sprintf("blob/%s/", ref),
294
-
fmt.Sprintf("tree/%s/", ref),
295
-
fmt.Sprintf("raw/%s/", ref),
296
-
}
231
+
re := regexp.MustCompile(pattern)
232
+
matches := re.FindStringSubmatch(fullPath)
297
233
298
-
for _, prefix := range prefixes {
299
-
idx := strings.Index(fullPath, prefix)
300
-
if idx != -1 {
301
-
return fullPath[idx+len(prefix):]
302
-
}
234
+
if len(matches) > 1 {
235
+
return matches[1]
303
236
}
304
237
305
238
return ""
+164
appview/serververify/verify.go
+164
appview/serververify/verify.go
···
1
+
package serververify
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"strings"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/appview/db"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
)
15
+
16
+
var (
17
+
FetchError = errors.New("failed to fetch owner")
18
+
)
19
+
20
+
// fetchOwner fetches the owner DID from a server's /owner endpoint
21
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
22
+
scheme := "https"
23
+
if dev {
24
+
scheme = "http"
25
+
}
26
+
27
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
+
req, err := http.NewRequest("GET", url, nil)
29
+
if err != nil {
30
+
return "", err
31
+
}
32
+
33
+
client := &http.Client{
34
+
Timeout: 1 * time.Second,
35
+
}
36
+
37
+
resp, err := client.Do(req.WithContext(ctx))
38
+
if err != nil || resp.StatusCode != 200 {
39
+
return "", fmt.Errorf("failed to fetch /owner")
40
+
}
41
+
42
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
+
if err != nil {
44
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
45
+
}
46
+
47
+
did := strings.TrimSpace(string(body))
48
+
if did == "" {
49
+
return "", fmt.Errorf("empty DID in /owner response")
50
+
}
51
+
52
+
return did, nil
53
+
}
54
+
55
+
type OwnerMismatch struct {
56
+
expected string
57
+
observed string
58
+
}
59
+
60
+
func (e *OwnerMismatch) Error() string {
61
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
62
+
}
63
+
64
+
// RunVerification verifies that the server at the given domain has the expected owner
65
+
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
66
+
observedOwner, err := fetchOwner(ctx, domain, dev)
67
+
if err != nil {
68
+
return fmt.Errorf("%w: %w", FetchError, err)
69
+
}
70
+
71
+
if observedOwner != expectedOwner {
72
+
return &OwnerMismatch{
73
+
expected: expectedOwner,
74
+
observed: observedOwner,
75
+
}
76
+
}
77
+
78
+
return nil
79
+
}
80
+
81
+
// MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner
82
+
func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
83
+
tx, err := d.Begin()
84
+
if err != nil {
85
+
return 0, fmt.Errorf("failed to create txn: %w", err)
86
+
}
87
+
defer func() {
88
+
tx.Rollback()
89
+
e.E.LoadPolicy()
90
+
}()
91
+
92
+
// mark this spindle as verified in the db
93
+
rowId, err := db.VerifySpindle(
94
+
tx,
95
+
db.FilterEq("owner", owner),
96
+
db.FilterEq("instance", instance),
97
+
)
98
+
if err != nil {
99
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
100
+
}
101
+
102
+
err = e.AddSpindleOwner(instance, owner)
103
+
if err != nil {
104
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
105
+
}
106
+
107
+
err = tx.Commit()
108
+
if err != nil {
109
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
110
+
}
111
+
112
+
err = e.E.SavePolicy()
113
+
if err != nil {
114
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
115
+
}
116
+
117
+
return rowId, nil
118
+
}
119
+
120
+
// MarkKnotVerified marks a knot as verified and sets up ownership/permissions
121
+
func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error {
122
+
tx, err := d.BeginTx(context.Background(), nil)
123
+
if err != nil {
124
+
return fmt.Errorf("failed to start tx: %w", err)
125
+
}
126
+
defer func() {
127
+
tx.Rollback()
128
+
e.E.LoadPolicy()
129
+
}()
130
+
131
+
// mark as registered
132
+
err = db.MarkRegistered(
133
+
tx,
134
+
db.FilterEq("did", owner),
135
+
db.FilterEq("domain", domain),
136
+
)
137
+
if err != nil {
138
+
return fmt.Errorf("failed to register domain: %w", err)
139
+
}
140
+
141
+
// add basic acls for this domain
142
+
err = e.AddKnot(domain)
143
+
if err != nil {
144
+
return fmt.Errorf("failed to add knot to enforcer: %w", err)
145
+
}
146
+
147
+
// add this did as owner of this domain
148
+
err = e.AddKnotOwner(domain, owner)
149
+
if err != nil {
150
+
return fmt.Errorf("failed to add knot owner to enforcer: %w", err)
151
+
}
152
+
153
+
err = tx.Commit()
154
+
if err != nil {
155
+
return fmt.Errorf("failed to commit changes: %w", err)
156
+
}
157
+
158
+
err = e.E.SavePolicy()
159
+
if err != nil {
160
+
return fmt.Errorf("failed to update ACLs: %w", err)
161
+
}
162
+
163
+
return nil
164
+
}
+44
-9
appview/settings/settings.go
+44
-9
appview/settings/settings.go
···
33
33
Config *config.Config
34
34
}
35
35
36
+
type tab = map[string]any
37
+
38
+
var (
39
+
settingsTabs []tab = []tab{
40
+
{"Name": "profile", "Icon": "user"},
41
+
{"Name": "keys", "Icon": "key"},
42
+
{"Name": "emails", "Icon": "mail"},
43
+
}
44
+
)
45
+
36
46
func (s *Settings) Router() http.Handler {
37
47
r := chi.NewRouter()
38
48
39
49
r.Use(middleware.AuthMiddleware(s.OAuth))
40
50
41
-
r.Get("/", s.settings)
51
+
// settings pages
52
+
r.Get("/", s.profileSettings)
53
+
r.Get("/profile", s.profileSettings)
42
54
43
55
r.Route("/keys", func(r chi.Router) {
56
+
r.Get("/", s.keysSettings)
44
57
r.Put("/", s.keys)
45
58
r.Delete("/", s.keys)
46
59
})
47
60
48
61
r.Route("/emails", func(r chi.Router) {
62
+
r.Get("/", s.emailsSettings)
49
63
r.Put("/", s.emails)
50
64
r.Delete("/", s.emails)
51
65
r.Get("/verify", s.emailsVerify)
···
56
70
return r
57
71
}
58
72
59
-
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
73
+
func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) {
74
+
user := s.OAuth.GetUser(r)
75
+
76
+
s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
77
+
LoggedInUser: user,
78
+
Tabs: settingsTabs,
79
+
Tab: "profile",
80
+
})
81
+
}
82
+
83
+
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
60
84
user := s.OAuth.GetUser(r)
61
85
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
62
86
if err != nil {
63
87
log.Println(err)
64
88
}
65
89
90
+
s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
91
+
LoggedInUser: user,
92
+
PubKeys: pubKeys,
93
+
Tabs: settingsTabs,
94
+
Tab: "keys",
95
+
})
96
+
}
97
+
98
+
func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) {
99
+
user := s.OAuth.GetUser(r)
66
100
emails, err := db.GetAllEmails(s.Db, user.Did)
67
101
if err != nil {
68
102
log.Println(err)
69
103
}
70
104
71
-
s.Pages.Settings(w, pages.SettingsParams{
105
+
s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
72
106
LoggedInUser: user,
73
-
PubKeys: pubKeys,
74
107
Emails: emails,
108
+
Tabs: settingsTabs,
109
+
Tab: "emails",
75
110
})
76
111
}
77
112
···
201
236
return
202
237
}
203
238
204
-
s.Pages.HxLocation(w, "/settings")
239
+
s.Pages.HxLocation(w, "/settings/emails")
205
240
return
206
241
}
207
242
}
···
244
279
return
245
280
}
246
281
247
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
282
+
http.Redirect(w, r, "/settings/emails", http.StatusSeeOther)
248
283
}
249
284
250
285
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
···
339
374
return
340
375
}
341
376
342
-
s.Pages.HxLocation(w, "/settings")
377
+
s.Pages.HxLocation(w, "/settings/emails")
343
378
}
344
379
345
380
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
···
410
445
return
411
446
}
412
447
413
-
s.Pages.HxLocation(w, "/settings")
448
+
s.Pages.HxLocation(w, "/settings/keys")
414
449
return
415
450
416
451
case http.MethodDelete:
···
455
490
}
456
491
log.Println("deleted successfully")
457
492
458
-
s.Pages.HxLocation(w, "/settings")
493
+
s.Pages.HxLocation(w, "/settings/keys")
459
494
return
460
495
}
461
496
}
+8
-21
appview/spindles/spindles.go
+8
-21
appview/spindles/spindles.go
···
15
15
"tangled.sh/tangled.sh/core/appview/middleware"
16
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
17
"tangled.sh/tangled.sh/core/appview/pages"
18
-
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/appview/serververify"
19
19
"tangled.sh/tangled.sh/core/idresolver"
20
20
"tangled.sh/tangled.sh/core/rbac"
21
21
"tangled.sh/tangled.sh/core/tid"
···
113
113
return
114
114
}
115
115
116
-
identsToResolve := make([]string, len(members))
117
-
copy(identsToResolve, members)
118
-
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
119
-
didHandleMap := make(map[string]string)
120
-
for _, identity := range resolvedIds {
121
-
if !identity.Handle.IsInvalidHandle() {
122
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
123
-
} else {
124
-
didHandleMap[identity.DID.String()] = identity.DID.String()
125
-
}
126
-
}
127
-
128
116
// organize repos by did
129
117
repoMap := make(map[string][]db.Repo)
130
118
for _, r := range repos {
···
136
124
Spindle: spindle,
137
125
Members: members,
138
126
Repos: repoMap,
139
-
DidHandleMap: didHandleMap,
140
127
})
141
128
}
142
129
···
240
227
}
241
228
242
229
// begin verification
243
-
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
230
+
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
244
231
if err != nil {
245
232
l.Error("verification failed", "err", err)
246
233
s.Pages.HxRefresh(w)
247
234
return
248
235
}
249
236
250
-
_, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
237
+
_, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
251
238
if err != nil {
252
239
l.Error("failed to mark verified", "err", err)
253
240
s.Pages.HxRefresh(w)
···
413
400
}
414
401
415
402
// begin verification
416
-
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
403
+
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
417
404
if err != nil {
418
405
l.Error("verification failed", "err", err)
419
406
420
-
if errors.Is(err, verify.FetchError) {
421
-
s.Pages.Notice(w, noticeId, err.Error())
407
+
if errors.Is(err, serververify.FetchError) {
408
+
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
422
409
return
423
410
}
424
411
425
-
if e, ok := err.(*verify.OwnerMismatch); ok {
412
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
426
413
s.Pages.Notice(w, noticeId, e.Error())
427
414
return
428
415
}
···
431
418
return
432
419
}
433
420
434
-
rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
421
+
rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
435
422
if err != nil {
436
423
l.Error("failed to mark verified", "err", err)
437
424
s.Pages.Notice(w, noticeId, err.Error())
-118
appview/spindleverify/verify.go
-118
appview/spindleverify/verify.go
···
1
-
package spindleverify
2
-
3
-
import (
4
-
"context"
5
-
"errors"
6
-
"fmt"
7
-
"io"
8
-
"net/http"
9
-
"strings"
10
-
"time"
11
-
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
)
15
-
16
-
var (
17
-
FetchError = errors.New("failed to fetch owner")
18
-
)
19
-
20
-
// TODO: move this to "spindleclient" or similar
21
-
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
22
-
scheme := "https"
23
-
if dev {
24
-
scheme = "http"
25
-
}
26
-
27
-
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
-
req, err := http.NewRequest("GET", url, nil)
29
-
if err != nil {
30
-
return "", err
31
-
}
32
-
33
-
client := &http.Client{
34
-
Timeout: 1 * time.Second,
35
-
}
36
-
37
-
resp, err := client.Do(req.WithContext(ctx))
38
-
if err != nil || resp.StatusCode != 200 {
39
-
return "", fmt.Errorf("failed to fetch /owner")
40
-
}
41
-
42
-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
-
if err != nil {
44
-
return "", fmt.Errorf("failed to read /owner response: %w", err)
45
-
}
46
-
47
-
did := strings.TrimSpace(string(body))
48
-
if did == "" {
49
-
return "", fmt.Errorf("empty DID in /owner response")
50
-
}
51
-
52
-
return did, nil
53
-
}
54
-
55
-
type OwnerMismatch struct {
56
-
expected string
57
-
observed string
58
-
}
59
-
60
-
func (e *OwnerMismatch) Error() string {
61
-
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
62
-
}
63
-
64
-
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
65
-
// begin verification
66
-
observedOwner, err := fetchOwner(ctx, instance, dev)
67
-
if err != nil {
68
-
return fmt.Errorf("%w: %w", FetchError, err)
69
-
}
70
-
71
-
if observedOwner != expectedOwner {
72
-
return &OwnerMismatch{
73
-
expected: expectedOwner,
74
-
observed: observedOwner,
75
-
}
76
-
}
77
-
78
-
return nil
79
-
}
80
-
81
-
// mark this spindle as verified in the DB and add this user as its owner
82
-
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
83
-
tx, err := d.Begin()
84
-
if err != nil {
85
-
return 0, fmt.Errorf("failed to create txn: %w", err)
86
-
}
87
-
defer func() {
88
-
tx.Rollback()
89
-
e.E.LoadPolicy()
90
-
}()
91
-
92
-
// mark this spindle as verified in the db
93
-
rowId, err := db.VerifySpindle(
94
-
tx,
95
-
db.FilterEq("owner", owner),
96
-
db.FilterEq("instance", instance),
97
-
)
98
-
if err != nil {
99
-
return 0, fmt.Errorf("failed to write to DB: %w", err)
100
-
}
101
-
102
-
err = e.AddSpindleOwner(instance, owner)
103
-
if err != nil {
104
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
105
-
}
106
-
107
-
err = tx.Commit()
108
-
if err != nil {
109
-
return 0, fmt.Errorf("failed to commit txn: %w", err)
110
-
}
111
-
112
-
err = e.E.SavePolicy()
113
-
if err != nil {
114
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
115
-
}
116
-
117
-
return rowId, nil
118
-
}
+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)
+5
-2
appview/state/knotstream.go
+5
-2
appview/state/knotstream.go
···
24
24
)
25
25
26
26
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
27
-
knots, err := db.GetCompletedRegistrations(d)
27
+
knots, err := db.GetRegistrations(
28
+
d,
29
+
db.FilterIsNot("registered", "null"),
30
+
)
28
31
if err != nil {
29
32
return nil, err
30
33
}
31
34
32
35
srcs := make(map[ec.Source]struct{})
33
36
for _, k := range knots {
34
-
s := ec.NewKnotSource(k)
37
+
s := ec.NewKnotSource(k.Domain)
35
38
srcs[s] = struct{}{}
36
39
}
37
40
+419
-112
appview/state/profile.go
+419
-112
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
+
"context"
4
5
"fmt"
5
6
"log"
6
7
"net/http"
···
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
14
15
lexutil "github.com/bluesky-social/indigo/lex/util"
15
16
"github.com/go-chi/chi/v5"
17
+
"github.com/gorilla/feeds"
16
18
"tangled.sh/tangled.sh/core/api/tangled"
17
19
"tangled.sh/tangled.sh/core/appview/db"
20
+
// "tangled.sh/tangled.sh/core/appview/oauth"
18
21
"tangled.sh/tangled.sh/core/appview/pages"
19
22
)
20
23
21
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
22
25
tabVal := r.URL.Query().Get("tab")
23
26
switch tabVal {
24
-
case "":
25
-
s.profilePage(w, r)
26
27
case "repos":
27
28
s.reposPage(w, r)
29
+
case "followers":
30
+
s.followersPage(w, r)
31
+
case "following":
32
+
s.followingPage(w, r)
33
+
case "starred":
34
+
s.starredPage(w, r)
35
+
case "strings":
36
+
s.stringsPage(w, r)
37
+
default:
38
+
s.profileOverview(w, r)
28
39
}
29
40
}
30
41
31
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
42
+
func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {
32
43
didOrHandle := chi.URLParam(r, "user")
33
44
if didOrHandle == "" {
34
-
http.Error(w, "Bad request", http.StatusBadRequest)
35
-
return
45
+
return nil, fmt.Errorf("empty DID or handle")
36
46
}
37
47
38
48
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
39
49
if !ok {
40
-
s.pages.Error404(w)
41
-
return
50
+
return nil, fmt.Errorf("failed to resolve ID")
51
+
}
52
+
did := ident.DID.String()
53
+
54
+
profile, err := db.GetProfile(s.db, did)
55
+
if err != nil {
56
+
return nil, fmt.Errorf("failed to get profile: %w", err)
57
+
}
58
+
59
+
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
60
+
if err != nil {
61
+
return nil, fmt.Errorf("failed to get repo count: %w", err)
62
+
}
63
+
64
+
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
65
+
if err != nil {
66
+
return nil, fmt.Errorf("failed to get string count: %w", err)
67
+
}
68
+
69
+
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
70
+
if err != nil {
71
+
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
+
}
73
+
74
+
followStats, err := db.GetFollowerFollowingCount(s.db, did)
75
+
if err != nil {
76
+
return nil, fmt.Errorf("failed to get follower stats: %w", err)
77
+
}
78
+
79
+
loggedInUser := s.oauth.GetUser(r)
80
+
followStatus := db.IsNotFollowing
81
+
if loggedInUser != nil {
82
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
83
+
}
84
+
85
+
now := time.Now()
86
+
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
87
+
punchcard, err := db.MakePunchcard(
88
+
s.db,
89
+
db.FilterEq("did", did),
90
+
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
91
+
db.FilterLte("date", now.Format(time.DateOnly)),
92
+
)
93
+
if err != nil {
94
+
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
42
95
}
43
96
44
-
profile, err := db.GetProfile(s.db, ident.DID.String())
97
+
return &pages.ProfileCard{
98
+
UserDid: did,
99
+
UserHandle: ident.Handle.String(),
100
+
Profile: profile,
101
+
FollowStatus: followStatus,
102
+
Stats: pages.ProfileStats{
103
+
RepoCount: repoCount,
104
+
StringCount: stringCount,
105
+
StarredCount: starredCount,
106
+
FollowersCount: followStats.Followers,
107
+
FollowingCount: followStats.Following,
108
+
},
109
+
Punchcard: punchcard,
110
+
}, nil
111
+
}
112
+
113
+
func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
114
+
l := s.logger.With("handler", "profileHomePage")
115
+
116
+
profile, err := s.profile(r)
45
117
if err != nil {
46
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
118
+
l.Error("failed to build profile card", "err", err)
119
+
s.pages.Error500(w)
120
+
return
47
121
}
122
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
48
123
49
124
repos, err := db.GetRepos(
50
125
s.db,
51
126
0,
52
-
db.FilterEq("did", ident.DID.String()),
127
+
db.FilterEq("did", profile.UserDid),
53
128
)
54
129
if err != nil {
55
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
130
+
l.Error("failed to fetch repos", "err", err)
56
131
}
57
132
58
133
// filter out ones that are pinned
59
134
pinnedRepos := []db.Repo{}
60
135
for i, r := range repos {
61
136
// if this is a pinned repo, add it
62
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
137
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
63
138
pinnedRepos = append(pinnedRepos, r)
64
139
}
65
140
66
141
// if there are no saved pins, add the first 4 repos
67
-
if profile.IsPinnedReposEmpty() && i < 4 {
142
+
if profile.Profile.IsPinnedReposEmpty() && i < 4 {
68
143
pinnedRepos = append(pinnedRepos, r)
69
144
}
70
145
}
71
146
72
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
147
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
73
148
if err != nil {
74
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
149
+
l.Error("failed to fetch collaborating repos", "err", err)
75
150
}
76
151
77
152
pinnedCollaboratingRepos := []db.Repo{}
78
153
for _, r := range collaboratingRepos {
79
154
// if this is a pinned repo, add it
80
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
155
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
81
156
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
82
157
}
83
158
}
84
159
85
-
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
160
+
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
86
161
if err != nil {
87
-
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
162
+
l.Error("failed to create timeline", "err", err)
88
163
}
89
164
90
-
var didsToResolve []string
91
-
for _, r := range collaboratingRepos {
92
-
didsToResolve = append(didsToResolve, r.Did)
165
+
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
166
+
LoggedInUser: s.oauth.GetUser(r),
167
+
Card: profile,
168
+
Repos: pinnedRepos,
169
+
CollaboratingRepos: pinnedCollaboratingRepos,
170
+
ProfileTimeline: timeline,
171
+
})
172
+
}
173
+
174
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
175
+
l := s.logger.With("handler", "reposPage")
176
+
177
+
profile, err := s.profile(r)
178
+
if err != nil {
179
+
l.Error("failed to build profile card", "err", err)
180
+
s.pages.Error500(w)
181
+
return
93
182
}
94
-
for _, byMonth := range timeline.ByMonth {
95
-
for _, pe := range byMonth.PullEvents.Items {
96
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
183
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
184
+
185
+
repos, err := db.GetRepos(
186
+
s.db,
187
+
0,
188
+
db.FilterEq("did", profile.UserDid),
189
+
)
190
+
if err != nil {
191
+
l.Error("failed to get repos", "err", err)
192
+
s.pages.Error500(w)
193
+
return
194
+
}
195
+
196
+
err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
197
+
LoggedInUser: s.oauth.GetUser(r),
198
+
Repos: repos,
199
+
Card: profile,
200
+
})
201
+
}
202
+
203
+
func (s *State) starredPage(w http.ResponseWriter, r *http.Request) {
204
+
l := s.logger.With("handler", "starredPage")
205
+
206
+
profile, err := s.profile(r)
207
+
if err != nil {
208
+
l.Error("failed to build profile card", "err", err)
209
+
s.pages.Error500(w)
210
+
return
211
+
}
212
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
213
+
214
+
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
215
+
if err != nil {
216
+
l.Error("failed to get stars", "err", err)
217
+
s.pages.Error500(w)
218
+
return
219
+
}
220
+
var repoAts []string
221
+
for _, s := range stars {
222
+
repoAts = append(repoAts, string(s.RepoAt))
223
+
}
224
+
225
+
repos, err := db.GetRepos(
226
+
s.db,
227
+
0,
228
+
db.FilterIn("at_uri", repoAts),
229
+
)
230
+
if err != nil {
231
+
l.Error("failed to get repos", "err", err)
232
+
s.pages.Error500(w)
233
+
return
234
+
}
235
+
236
+
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
237
+
LoggedInUser: s.oauth.GetUser(r),
238
+
Repos: repos,
239
+
Card: profile,
240
+
})
241
+
}
242
+
243
+
func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) {
244
+
l := s.logger.With("handler", "stringsPage")
245
+
246
+
profile, err := s.profile(r)
247
+
if err != nil {
248
+
l.Error("failed to build profile card", "err", err)
249
+
s.pages.Error500(w)
250
+
return
251
+
}
252
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
253
+
254
+
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
255
+
if err != nil {
256
+
l.Error("failed to get strings", "err", err)
257
+
s.pages.Error500(w)
258
+
return
259
+
}
260
+
261
+
err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{
262
+
LoggedInUser: s.oauth.GetUser(r),
263
+
Strings: strings,
264
+
Card: profile,
265
+
})
266
+
}
267
+
268
+
type FollowsPageParams struct {
269
+
Follows []pages.FollowCard
270
+
Card *pages.ProfileCard
271
+
}
272
+
273
+
func (s *State) followPage(
274
+
r *http.Request,
275
+
fetchFollows func(db.Execer, string) ([]db.Follow, error),
276
+
extractDid func(db.Follow) string,
277
+
) (*FollowsPageParams, error) {
278
+
l := s.logger.With("handler", "reposPage")
279
+
280
+
profile, err := s.profile(r)
281
+
if err != nil {
282
+
return nil, err
283
+
}
284
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
285
+
286
+
loggedInUser := s.oauth.GetUser(r)
287
+
288
+
follows, err := fetchFollows(s.db, profile.UserDid)
289
+
if err != nil {
290
+
l.Error("failed to fetch follows", "err", err)
291
+
return nil, err
292
+
}
293
+
294
+
if len(follows) == 0 {
295
+
return nil, nil
296
+
}
297
+
298
+
followDids := make([]string, 0, len(follows))
299
+
for _, follow := range follows {
300
+
followDids = append(followDids, extractDid(follow))
301
+
}
302
+
303
+
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
304
+
if err != nil {
305
+
l.Error("failed to get profiles", "followDids", followDids, "err", err)
306
+
return nil, err
307
+
}
308
+
309
+
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
310
+
if err != nil {
311
+
log.Printf("getting follow counts for %s: %s", followDids, err)
312
+
}
313
+
314
+
loggedInUserFollowing := make(map[string]struct{})
315
+
if loggedInUser != nil {
316
+
following, err := db.GetFollowing(s.db, loggedInUser.Did)
317
+
if err != nil {
318
+
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
319
+
return nil, err
97
320
}
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
-
}
321
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
322
+
for _, follow := range following {
323
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
106
324
}
107
325
}
108
326
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())
327
+
followCards := make([]pages.FollowCard, len(follows))
328
+
for i, did := range followDids {
329
+
followStats := followStatsMap[did]
330
+
followStatus := db.IsNotFollowing
331
+
if _, exists := loggedInUserFollowing[did]; exists {
332
+
followStatus = db.IsFollowing
333
+
} else if loggedInUser != nil && loggedInUser.Did == did {
334
+
followStatus = db.IsSelf
335
+
}
336
+
337
+
var profile *db.Profile
338
+
if p, exists := profiles[did]; exists {
339
+
profile = p
114
340
} else {
115
-
didHandleMap[identity.DID.String()] = identity.DID.String()
341
+
profile = &db.Profile{}
342
+
profile.Did = did
343
+
}
344
+
followCards[i] = pages.FollowCard{
345
+
UserDid: did,
346
+
FollowStatus: followStatus,
347
+
FollowersCount: followStats.Followers,
348
+
FollowingCount: followStats.Following,
349
+
Profile: profile,
116
350
}
117
351
}
118
352
119
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
353
+
return &FollowsPageParams{
354
+
Follows: followCards,
355
+
Card: profile,
356
+
}, nil
357
+
}
358
+
359
+
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
360
+
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
120
361
if err != nil {
121
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
362
+
s.pages.Notice(w, "all-followers", "Failed to load followers")
363
+
return
122
364
}
123
365
124
-
loggedInUser := s.oauth.GetUser(r)
125
-
followStatus := db.IsNotFollowing
126
-
if loggedInUser != nil {
127
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
128
-
}
366
+
s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
367
+
LoggedInUser: s.oauth.GetUser(r),
368
+
Followers: followPage.Follows,
369
+
Card: followPage.Card,
370
+
})
371
+
}
129
372
130
-
now := time.Now()
131
-
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
132
-
punchcard, err := db.MakePunchcard(
133
-
s.db,
134
-
db.FilterEq("did", ident.DID.String()),
135
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
136
-
db.FilterLte("date", now.Format(time.DateOnly)),
137
-
)
373
+
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
374
+
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
138
375
if err != nil {
139
-
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
376
+
s.pages.Notice(w, "all-following", "Failed to load following")
377
+
return
140
378
}
141
379
142
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
143
-
LoggedInUser: loggedInUser,
144
-
Repos: pinnedRepos,
145
-
CollaboratingRepos: pinnedCollaboratingRepos,
146
-
DidHandleMap: didHandleMap,
147
-
Card: pages.ProfileCard{
148
-
UserDid: ident.DID.String(),
149
-
UserHandle: ident.Handle.String(),
150
-
Profile: profile,
151
-
FollowStatus: followStatus,
152
-
Followers: followers,
153
-
Following: following,
154
-
},
155
-
Punchcard: punchcard,
156
-
ProfileTimeline: timeline,
380
+
s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
381
+
LoggedInUser: s.oauth.GetUser(r),
382
+
Following: followPage.Follows,
383
+
Card: followPage.Card,
157
384
})
158
385
}
159
386
160
-
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
387
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
161
388
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
162
389
if !ok {
163
390
s.pages.Error404(w)
164
391
return
165
392
}
166
393
167
-
profile, err := db.GetProfile(s.db, ident.DID.String())
394
+
feed, err := s.getProfileFeed(r.Context(), &ident)
395
+
if err != nil {
396
+
s.pages.Error500(w)
397
+
return
398
+
}
399
+
400
+
if feed == nil {
401
+
return
402
+
}
403
+
404
+
atom, err := feed.ToAtom()
168
405
if err != nil {
169
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
406
+
s.pages.Error500(w)
407
+
return
170
408
}
171
409
172
-
repos, err := db.GetRepos(
173
-
s.db,
174
-
0,
175
-
db.FilterEq("did", ident.DID.String()),
176
-
)
410
+
w.Header().Set("content-type", "application/atom+xml")
411
+
w.Write([]byte(atom))
412
+
}
413
+
414
+
func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
415
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
177
416
if err != nil {
178
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
417
+
return nil, err
418
+
}
419
+
420
+
author := &feeds.Author{
421
+
Name: fmt.Sprintf("@%s", id.Handle),
179
422
}
180
423
181
-
loggedInUser := s.oauth.GetUser(r)
182
-
followStatus := db.IsNotFollowing
183
-
if loggedInUser != nil {
184
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
424
+
feed := feeds.Feed{
425
+
Title: fmt.Sprintf("%s's timeline", author.Name),
426
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
427
+
Items: make([]*feeds.Item, 0),
428
+
Updated: time.UnixMilli(0),
429
+
Author: author,
185
430
}
186
431
187
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
188
-
if err != nil {
189
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
432
+
for _, byMonth := range timeline.ByMonth {
433
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
434
+
return nil, err
435
+
}
436
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
437
+
return nil, err
438
+
}
439
+
if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
440
+
return nil, err
441
+
}
190
442
}
191
443
192
-
s.pages.ReposPage(w, pages.ReposPageParams{
193
-
LoggedInUser: loggedInUser,
194
-
Repos: repos,
195
-
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
196
-
Card: pages.ProfileCard{
197
-
UserDid: ident.DID.String(),
198
-
UserHandle: ident.Handle.String(),
199
-
Profile: profile,
200
-
FollowStatus: followStatus,
201
-
Followers: followers,
202
-
Following: following,
203
-
},
444
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
445
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
204
446
})
447
+
448
+
if len(feed.Items) > 0 {
449
+
feed.Updated = feed.Items[0].Created
450
+
}
451
+
452
+
return &feed, nil
453
+
}
454
+
455
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
456
+
for _, pull := range pulls {
457
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
458
+
if err != nil {
459
+
return err
460
+
}
461
+
462
+
// Add pull request creation item
463
+
feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
464
+
}
465
+
return nil
466
+
}
467
+
468
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
469
+
for _, issue := range issues {
470
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
471
+
if err != nil {
472
+
return err
473
+
}
474
+
475
+
feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
476
+
}
477
+
return nil
478
+
}
479
+
480
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
481
+
for _, repo := range repos {
482
+
item, err := s.createRepoItem(ctx, repo, author)
483
+
if err != nil {
484
+
return err
485
+
}
486
+
feed.Items = append(feed.Items, item)
487
+
}
488
+
return nil
489
+
}
490
+
491
+
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
492
+
return &feeds.Item{
493
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
494
+
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"},
495
+
Created: pull.Created,
496
+
Author: author,
497
+
}
498
+
}
499
+
500
+
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
501
+
return &feeds.Item{
502
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
503
+
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"},
504
+
Created: issue.Created,
505
+
Author: author,
506
+
}
507
+
}
508
+
509
+
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
510
+
var title string
511
+
if repo.Source != nil {
512
+
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
513
+
if err != nil {
514
+
return nil, err
515
+
}
516
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
517
+
} else {
518
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
519
+
}
520
+
521
+
return &feeds.Item{
522
+
Title: title,
523
+
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
524
+
Created: repo.Repo.Created,
525
+
Author: author,
526
+
}, nil
205
527
}
206
528
207
529
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
406
728
})
407
729
}
408
730
409
-
var didsToResolve []string
410
-
for _, r := range allRepos {
411
-
didsToResolve = append(didsToResolve, r.Did)
412
-
}
413
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
414
-
didHandleMap := make(map[string]string)
415
-
for _, identity := range resolvedIds {
416
-
if !identity.Handle.IsInvalidHandle() {
417
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
418
-
} else {
419
-
didHandleMap[identity.DID.String()] = identity.DID.String()
420
-
}
421
-
}
422
-
423
731
s.pages.EditPinsFragment(w, pages.EditPinsParams{
424
732
LoggedInUser: user,
425
733
Profile: profile,
426
734
AllRepos: allRepos,
427
-
DidHandleMap: didHandleMap,
428
735
})
429
736
}
+18
-6
appview/state/router.go
+18
-6
appview/state/router.go
···
32
32
s.pages,
33
33
)
34
34
35
+
router.Get("/favicon.svg", s.Favicon)
36
+
router.Get("/favicon.ico", s.Favicon)
37
+
38
+
userRouter := s.UserRouter(&middleware)
39
+
standardRouter := s.StandardRouter(&middleware)
40
+
35
41
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
36
42
pat := chi.URLParam(r, "*")
37
43
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
38
-
s.UserRouter(&middleware).ServeHTTP(w, r)
44
+
userRouter.ServeHTTP(w, r)
39
45
} else {
40
46
// Check if the first path element is a valid handle without '@' or a flattened DID
41
47
pathParts := strings.SplitN(pat, "/", 2)
···
58
64
return
59
65
}
60
66
}
61
-
s.StandardRouter(&middleware).ServeHTTP(w, r)
67
+
standardRouter.ServeHTTP(w, r)
62
68
}
63
69
})
64
70
···
70
76
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))
···
135
147
136
148
r.Mount("/settings", s.SettingsRouter())
137
149
r.Mount("/strings", s.StringsRouter(mw))
138
-
r.Mount("/knots", s.KnotsRouter(mw))
150
+
r.Mount("/knots", s.KnotsRouter())
139
151
r.Mount("/spindles", s.SpindlesRouter())
140
152
r.Mount("/signup", s.SignupRouter())
141
153
r.Mount("/", s.OAuthRouter())
···
183
195
return spindles.Router()
184
196
}
185
197
186
-
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
198
+
func (s *State) KnotsRouter() http.Handler {
187
199
logger := log.New("knots")
188
200
189
201
knots := &knots.Knots{
···
197
209
Logger: logger,
198
210
}
199
211
200
-
return knots.Router(mw)
212
+
return knots.Router()
201
213
}
202
214
203
215
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+123
-68
appview/state/state.go
+123
-68
appview/state/state.go
···
2
2
3
3
import (
4
4
"context"
5
+
"database/sql"
6
+
"errors"
5
7
"fmt"
6
8
"log"
7
9
"log/slog"
···
10
12
"time"
11
13
12
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
16
lexutil "github.com/bluesky-social/indigo/lex/util"
14
17
securejoin "github.com/cyphar/filepath-securejoin"
15
18
"github.com/go-chi/chi/v5"
···
25
28
"tangled.sh/tangled.sh/core/appview/pages"
26
29
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
28
32
"tangled.sh/tangled.sh/core/eventconsumer"
29
33
"tangled.sh/tangled.sh/core/idresolver"
30
34
"tangled.sh/tangled.sh/core/jetstream"
31
-
"tangled.sh/tangled.sh/core/knotclient"
32
35
tlog "tangled.sh/tangled.sh/core/log"
33
36
"tangled.sh/tangled.sh/core/rbac"
34
37
"tangled.sh/tangled.sh/core/tid"
38
+
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
35
39
)
36
40
37
41
type State struct {
···
48
52
repoResolver *reporesolver.RepoResolver
49
53
knotstream *eventconsumer.Consumer
50
54
spindlestream *eventconsumer.Consumer
55
+
logger *slog.Logger
51
56
}
52
57
53
58
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
61
66
return nil, fmt.Errorf("failed to create enforcer: %w", err)
62
67
}
63
68
64
-
pgs := pages.NewPages(config)
65
-
66
69
res, err := idresolver.RedisResolver(config.Redis.ToURL())
67
70
if err != nil {
68
71
log.Printf("failed to create redis resolver: %v", err)
69
72
res = idresolver.DefaultResolver()
70
73
}
74
+
75
+
pgs := pages.NewPages(config, res)
71
76
72
77
cache := cache.New(config.Redis.Addr)
73
78
sess := session.New(cache)
···
94
99
tangled.SpindleMemberNSID,
95
100
tangled.SpindleNSID,
96
101
tangled.StringNSID,
102
+
tangled.RepoIssueNSID,
103
+
tangled.RepoIssueCommentNSID,
97
104
},
98
105
nil,
99
106
slog.Default(),
···
152
159
repoResolver,
153
160
knotstream,
154
161
spindlestream,
162
+
slog.Default(),
155
163
}
156
164
157
165
return state, nil
158
166
}
159
167
168
+
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
169
+
w.Header().Set("Content-Type", "image/svg+xml")
170
+
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
171
+
w.Header().Set("ETag", `"favicon-svg-v1"`)
172
+
173
+
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
174
+
w.WriteHeader(http.StatusNotModified)
175
+
return
176
+
}
177
+
178
+
s.pages.Favicon(w)
179
+
}
180
+
160
181
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
161
182
user := s.oauth.GetUser(r)
162
183
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
180
201
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
181
202
}
182
203
183
-
var didsToResolve []string
184
-
for _, ev := range timeline {
185
-
if ev.Repo != nil {
186
-
didsToResolve = append(didsToResolve, ev.Repo.Did)
187
-
if ev.Source != nil {
188
-
didsToResolve = append(didsToResolve, ev.Source.Did)
189
-
}
190
-
}
191
-
if ev.Follow != nil {
192
-
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
193
-
}
194
-
if ev.Star != nil {
195
-
didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
196
-
}
197
-
}
198
-
199
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
200
-
didHandleMap := make(map[string]string)
201
-
for _, identity := range resolvedIds {
202
-
if !identity.Handle.IsInvalidHandle() {
203
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
204
-
} else {
205
-
didHandleMap[identity.DID.String()] = identity.DID.String()
206
-
}
204
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
205
+
if err != nil {
206
+
log.Println(err)
207
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
208
+
return
207
209
}
208
210
209
211
s.pages.Timeline(w, pages.TimelineParams{
210
212
LoggedInUser: user,
211
213
Timeline: timeline,
212
-
DidHandleMap: didHandleMap,
214
+
Repos: repos,
213
215
})
214
-
215
-
return
216
216
}
217
217
218
218
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
···
279
279
return nil
280
280
}
281
281
282
+
func stripGitExt(name string) string {
283
+
return strings.TrimSuffix(name, ".git")
284
+
}
285
+
282
286
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
283
287
switch r.Method {
284
288
case http.MethodGet:
···
295
299
})
296
300
297
301
case http.MethodPost:
302
+
l := s.logger.With("handler", "NewRepo")
303
+
298
304
user := s.oauth.GetUser(r)
305
+
l = l.With("did", user.Did)
306
+
l = l.With("handle", user.Handle)
299
307
308
+
// form validation
300
309
domain := r.FormValue("domain")
301
310
if domain == "" {
302
311
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
303
312
return
304
313
}
314
+
l = l.With("knot", domain)
305
315
306
316
repoName := r.FormValue("name")
307
317
if repoName == "" {
···
313
323
s.pages.Notice(w, "repo", err.Error())
314
324
return
315
325
}
326
+
repoName = stripGitExt(repoName)
327
+
l = l.With("repoName", repoName)
316
328
317
329
defaultBranch := r.FormValue("branch")
318
330
if defaultBranch == "" {
319
331
defaultBranch = "main"
320
332
}
333
+
l = l.With("defaultBranch", defaultBranch)
321
334
322
335
description := r.FormValue("description")
323
336
337
+
// ACL validation
324
338
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
325
339
if err != nil || !ok {
340
+
l.Info("unauthorized")
326
341
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
327
342
return
328
343
}
329
344
345
+
// Check for existing repos
330
346
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
331
347
if err == nil && existingRepo != nil {
332
-
s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
333
-
return
334
-
}
335
-
336
-
secret, err := db.GetRegistrationKey(s.db, domain)
337
-
if err != nil {
338
-
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
339
-
return
340
-
}
341
-
342
-
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
343
-
if err != nil {
344
-
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
348
+
l.Info("repo exists")
349
+
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
345
350
return
346
351
}
347
352
353
+
// create atproto record for this repo
348
354
rkey := tid.TID()
349
355
repo := &db.Repo{
350
356
Did: user.Did,
···
356
362
357
363
xrpcClient, err := s.oauth.AuthorizedClient(r)
358
364
if err != nil {
365
+
l.Info("PDS write failed", "err", err)
359
366
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
360
367
return
361
368
}
···
374
381
}},
375
382
})
376
383
if err != nil {
377
-
log.Printf("failed to create record: %s", err)
384
+
l.Info("PDS write failed", "err", err)
378
385
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
379
386
return
380
387
}
381
-
log.Println("created repo record: ", atresp.Uri)
388
+
389
+
aturi := atresp.Uri
390
+
l = l.With("aturi", aturi)
391
+
l.Info("wrote to PDS")
382
392
383
393
tx, err := s.db.BeginTx(r.Context(), nil)
384
394
if err != nil {
385
-
log.Println(err)
395
+
l.Info("txn failed", "err", err)
386
396
s.pages.Notice(w, "repo", "Failed to save repository information.")
387
397
return
388
398
}
389
-
defer func() {
390
-
tx.Rollback()
391
-
err = s.enforcer.E.LoadPolicy()
392
-
if err != nil {
393
-
log.Println("failed to rollback policies")
399
+
400
+
// The rollback function reverts a few things on failure:
401
+
// - the pending txn
402
+
// - the ACLs
403
+
// - the atproto record created
404
+
rollback := func() {
405
+
err1 := tx.Rollback()
406
+
err2 := s.enforcer.E.LoadPolicy()
407
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
408
+
409
+
// ignore txn complete errors, this is okay
410
+
if errors.Is(err1, sql.ErrTxDone) {
411
+
err1 = nil
394
412
}
395
-
}()
396
413
397
-
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
414
+
if errs := errors.Join(err1, err2, err3); errs != nil {
415
+
l.Error("failed to rollback changes", "errs", errs)
416
+
return
417
+
}
418
+
}
419
+
defer rollback()
420
+
421
+
client, err := s.oauth.ServiceClient(
422
+
r,
423
+
oauth.WithService(domain),
424
+
oauth.WithLxm(tangled.RepoCreateNSID),
425
+
oauth.WithDev(s.config.Core.Dev),
426
+
)
398
427
if err != nil {
399
-
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
428
+
l.Error("service auth failed", "err", err)
429
+
s.pages.Notice(w, "repo", "Failed to reach PDS.")
400
430
return
401
431
}
402
432
403
-
switch resp.StatusCode {
404
-
case http.StatusConflict:
405
-
s.pages.Notice(w, "repo", "A repository with that name already exists.")
433
+
xe := tangled.RepoCreate(
434
+
r.Context(),
435
+
client,
436
+
&tangled.RepoCreate_Input{
437
+
Rkey: rkey,
438
+
},
439
+
)
440
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
441
+
l.Error("xrpc error", "xe", xe)
442
+
s.pages.Notice(w, "repo", err.Error())
406
443
return
407
-
case http.StatusInternalServerError:
408
-
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
409
-
case http.StatusNoContent:
410
-
// continue
411
444
}
412
445
413
-
repo.AtUri = atresp.Uri
414
446
err = db.AddRepo(tx, repo)
415
447
if err != nil {
416
-
log.Println(err)
448
+
l.Error("db write failed", "err", err)
417
449
s.pages.Notice(w, "repo", "Failed to save repository information.")
418
450
return
419
451
}
···
422
454
p, _ := securejoin.SecureJoin(user.Did, repoName)
423
455
err = s.enforcer.AddRepo(user.Did, domain, p)
424
456
if err != nil {
425
-
log.Println(err)
457
+
l.Error("acl setup failed", "err", err)
426
458
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
427
459
return
428
460
}
429
461
430
462
err = tx.Commit()
431
463
if err != nil {
432
-
log.Println("failed to commit changes", err)
464
+
l.Error("txn commit failed", "err", err)
433
465
http.Error(w, err.Error(), http.StatusInternalServerError)
434
466
return
435
467
}
436
468
437
469
err = s.enforcer.E.SavePolicy()
438
470
if err != nil {
439
-
log.Println("failed to update ACLs", err)
471
+
l.Error("acl save failed", "err", err)
440
472
http.Error(w, err.Error(), http.StatusInternalServerError)
441
473
return
442
474
}
443
475
476
+
// reset the ATURI because the transaction completed successfully
477
+
aturi = ""
478
+
444
479
s.notifier.NewRepo(r.Context(), repo)
480
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
481
+
}
482
+
}
445
483
446
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
447
-
return
484
+
// this is used to rollback changes made to the PDS
485
+
//
486
+
// it is a no-op if the provided ATURI is empty
487
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
488
+
if aturi == "" {
489
+
return nil
448
490
}
491
+
492
+
parsed := syntax.ATURI(aturi)
493
+
494
+
collection := parsed.Collection().String()
495
+
repo := parsed.Authority().String()
496
+
rkey := parsed.RecordKey().String()
497
+
498
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
499
+
Collection: collection,
500
+
Repo: repo,
501
+
Rkey: rkey,
502
+
})
503
+
return err
449
504
}
+23
-70
appview/strings/strings.go
+23
-70
appview/strings/strings.go
···
5
5
"log/slog"
6
6
"net/http"
7
7
"path"
8
-
"slices"
9
8
"strconv"
10
-
"strings"
11
9
"time"
12
10
13
11
"tangled.sh/tangled.sh/core/api/tangled"
···
44
42
r := chi.NewRouter()
45
43
46
44
r.
45
+
Get("/", s.timeline)
46
+
47
+
r.
47
48
With(mw.ResolveIdent()).
48
49
Route("/{user}", func(r chi.Router) {
49
50
r.Get("/", s.dashboard)
···
70
71
return r
71
72
}
72
73
74
+
func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
75
+
l := s.Logger.With("handler", "timeline")
76
+
77
+
strings, err := db.GetStrings(s.Db, 50)
78
+
if err != nil {
79
+
l.Error("failed to fetch string", "err", err)
80
+
w.WriteHeader(http.StatusInternalServerError)
81
+
return
82
+
}
83
+
84
+
s.Pages.StringsTimeline(w, pages.StringTimelineParams{
85
+
LoggedInUser: s.OAuth.GetUser(r),
86
+
Strings: strings,
87
+
})
88
+
}
89
+
73
90
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
74
91
l := s.Logger.With("handler", "contents")
75
92
···
91
108
92
109
strings, err := db.GetStrings(
93
110
s.Db,
111
+
0,
94
112
db.FilterEq("did", id.DID),
95
113
db.FilterEq("rkey", rkey),
96
114
)
···
142
160
}
143
161
144
162
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
145
-
l := s.Logger.With("handler", "dashboard")
146
-
147
-
id, ok := r.Context().Value("resolvedId").(identity.Identity)
148
-
if !ok {
149
-
l.Error("malformed middleware")
150
-
w.WriteHeader(http.StatusInternalServerError)
151
-
return
152
-
}
153
-
l = l.With("did", id.DID, "handle", id.Handle)
154
-
155
-
all, err := db.GetStrings(
156
-
s.Db,
157
-
db.FilterEq("did", id.DID),
158
-
)
159
-
if err != nil {
160
-
l.Error("failed to fetch strings", "err", err)
161
-
w.WriteHeader(http.StatusInternalServerError)
162
-
return
163
-
}
164
-
165
-
slices.SortFunc(all, func(a, b db.String) int {
166
-
if a.Created.After(b.Created) {
167
-
return -1
168
-
} else {
169
-
return 1
170
-
}
171
-
})
172
-
173
-
profile, err := db.GetProfile(s.Db, id.DID.String())
174
-
if err != nil {
175
-
l.Error("failed to fetch user profile", "err", err)
176
-
w.WriteHeader(http.StatusInternalServerError)
177
-
return
178
-
}
179
-
loggedInUser := s.OAuth.GetUser(r)
180
-
followStatus := db.IsNotFollowing
181
-
if loggedInUser != nil {
182
-
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
183
-
}
184
-
185
-
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
186
-
if err != nil {
187
-
l.Error("failed to get follow stats", "err", err)
188
-
}
189
-
190
-
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
191
-
LoggedInUser: s.OAuth.GetUser(r),
192
-
Card: pages.ProfileCard{
193
-
UserDid: id.DID.String(),
194
-
UserHandle: id.Handle.String(),
195
-
Profile: profile,
196
-
FollowStatus: followStatus,
197
-
Followers: followers,
198
-
Following: following,
199
-
},
200
-
Strings: all,
201
-
})
163
+
http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
202
164
}
203
165
204
166
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
···
225
187
// get the string currently being edited
226
188
all, err := db.GetStrings(
227
189
s.Db,
190
+
0,
228
191
db.FilterEq("did", id.DID),
229
192
db.FilterEq("rkey", rkey),
230
193
)
···
266
229
fail("Empty filename.", nil)
267
230
return
268
231
}
269
-
if !strings.Contains(filename, ".") {
270
-
// TODO: make this a htmx form validation
271
-
fail("No extension provided for filename.", nil)
272
-
return
273
-
}
274
232
275
233
content := r.FormValue("content")
276
234
if content == "" {
···
353
311
fail("Empty filename.", nil)
354
312
return
355
313
}
356
-
if !strings.Contains(filename, ".") {
357
-
// TODO: make this a htmx form validation
358
-
fail("No extension provided for filename.", nil)
359
-
return
360
-
}
361
314
362
315
content := r.FormValue("content")
363
316
if content == "" {
···
434
387
}
435
388
436
389
if user.Did != id.DID.String() {
437
-
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
390
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
438
391
return
439
392
}
440
393
+25
appview/xrpcclient/xrpc.go
+25
appview/xrpcclient/xrpc.go
···
3
3
import (
4
4
"bytes"
5
5
"context"
6
+
"errors"
7
+
"fmt"
6
8
"io"
9
+
"net/http"
7
10
8
11
"github.com/bluesky-social/indigo/api/atproto"
9
12
"github.com/bluesky-social/indigo/xrpc"
13
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
10
14
oauth "tangled.sh/icyphox.sh/atproto-oauth"
11
15
)
12
16
···
102
106
103
107
return &out, nil
104
108
}
109
+
110
+
// produces a more manageable error
111
+
func HandleXrpcErr(err error) error {
112
+
if err == nil {
113
+
return nil
114
+
}
115
+
116
+
var xrpcerr *indigoxrpc.Error
117
+
if ok := errors.As(err, &xrpcerr); !ok {
118
+
return fmt.Errorf("Recieved invalid XRPC error response.")
119
+
}
120
+
121
+
switch xrpcerr.StatusCode {
122
+
case http.StatusNotFound:
123
+
return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.")
124
+
case http.StatusUnauthorized:
125
+
return fmt.Errorf("Unauthorized XRPC request.")
126
+
default:
127
+
return fmt.Errorf("Failed to perform operation. Try again later.")
128
+
}
129
+
}
+6
-6
cmd/gen.go
+6
-6
cmd/gen.go
···
18
18
tangled.FeedReaction{},
19
19
tangled.FeedStar{},
20
20
tangled.GitRefUpdate{},
21
+
tangled.GitRefUpdate_CommitCountBreakdown{},
22
+
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
+
tangled.GitRefUpdate_LangBreakdown{},
24
+
tangled.GitRefUpdate_IndividualLanguageSize{},
21
25
tangled.GitRefUpdate_Meta{},
22
-
tangled.GitRefUpdate_Meta_CommitCount{},
23
-
tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{},
24
-
tangled.GitRefUpdate_Meta_LangBreakdown{},
25
-
tangled.GitRefUpdate_Pair{},
26
26
tangled.GraphFollow{},
27
+
tangled.Knot{},
27
28
tangled.KnotMember{},
28
29
tangled.Pipeline{},
29
30
tangled.Pipeline_CloneOpts{},
30
-
tangled.Pipeline_Dependency{},
31
31
tangled.Pipeline_ManualTriggerData{},
32
32
tangled.Pipeline_Pair{},
33
33
tangled.Pipeline_PullRequestTriggerData{},
34
34
tangled.Pipeline_PushTriggerData{},
35
35
tangled.PipelineStatus{},
36
-
tangled.Pipeline_Step{},
37
36
tangled.Pipeline_TriggerMetadata{},
38
37
tangled.Pipeline_TriggerRepo{},
39
38
tangled.Pipeline_Workflow{},
···
48
47
tangled.RepoPullComment{},
49
48
tangled.RepoPull_Source{},
50
49
tangled.RepoPullStatus{},
50
+
tangled.RepoPull_Target{},
51
51
tangled.Spindle{},
52
52
tangled.SpindleMember{},
53
53
tangled.String{},
+4
cmd/genjwks/main.go
+4
cmd/genjwks/main.go
+1
-1
cmd/punchcardPopulate/main.go
+1
-1
cmd/punchcardPopulate/main.go
+17
-18
docs/contributing.md
+17
-18
docs/contributing.md
···
11
11
### message format
12
12
13
13
```
14
-
<service/top-level directory>: <affected package/directory>: <short summary of change>
14
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
15
15
16
16
17
17
Optional longer description can go here, if necessary. Explain what the
···
23
23
Here are some examples:
24
24
25
25
```
26
-
appview: state: fix token expiry check in middleware
26
+
appview/state: fix token expiry check in middleware
27
27
28
28
The previous check did not account for clock drift, leading to premature
29
29
token invalidation.
30
30
```
31
31
32
32
```
33
-
knotserver: git/service: improve error checking in upload-pack
33
+
knotserver/git/service: improve error checking in upload-pack
34
34
```
35
35
36
36
···
54
54
- Don't include unrelated changes in the same commit.
55
55
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
56
56
before submitting if necessary.
57
+
58
+
## code formatting
59
+
60
+
We use a variety of tools to format our code, and multiplex them with
61
+
[`treefmt`](https://treefmt.com): all you need to do to format your changes
62
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
57
63
58
64
## proposals for bigger changes
59
65
···
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.
+66
-19
docs/hacking.md
+66
-19
docs/hacking.md
···
48
48
redis-server
49
49
```
50
50
51
-
## running a knot
51
+
## running knots and spindles
52
52
53
53
An end-to-end knot setup requires setting up a machine with
54
54
`sshd`, `AuthorizedKeysCommand`, and git user, which is
55
55
quite cumbersome. So the nix flake provides a
56
56
`nixosConfiguration` to do so.
57
57
58
-
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
60
-
ideally in a `.envrc` with [direnv](https://direnv.net) so you
61
-
don't lose it.
58
+
<details>
59
+
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
60
+
61
+
In order to build Tangled's dev VM on macOS, you will
62
+
first need to set up a Linux Nix builder. The recommended
63
+
way to do so is to run a [`darwin.linux-builder`
64
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
65
+
and to register it in `nix.conf` as a builder for Linux
66
+
with the same architecture as your Mac (`linux-aarch64` if
67
+
you are using Apple Silicon).
68
+
69
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
70
+
> the tangled repo so that it doesn't conflict with the other VM. For example,
71
+
> you can do
72
+
>
73
+
> ```shell
74
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
75
+
> ```
76
+
>
77
+
> to store the builder VM in a temporary dir.
78
+
>
79
+
> You should read and follow [all the other intructions][darwin builder vm] to
80
+
> avoid subtle problems.
81
+
82
+
Alternatively, you can use any other method to set up a
83
+
Linux machine with `nix` installed that you can `sudo ssh`
84
+
into (in other words, root user on your Mac has to be able
85
+
to ssh into the Linux machine without entering a password)
86
+
and that has the same architecture as your Mac. See
87
+
[remote builder
88
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
89
+
for how to register such a builder in `nix.conf`.
90
+
91
+
> WARNING: If you'd like to use
92
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
93
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
94
+
> ssh` works can be tricky. It seems to be [possible with
95
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
96
+
97
+
</details>
62
98
63
-
You can now start a lightweight NixOS VM using
64
-
`nixos-shell` like so:
99
+
To begin, grab your DID from http://localhost:3000/settings.
100
+
Then, set `TANGLED_VM_KNOT_OWNER` and
101
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
102
+
lightweight NixOS VM like so:
65
103
66
104
```bash
67
-
nix run .#vm
68
-
# or nixos-shell --flake .#vm
105
+
nix run --impure .#vm
69
106
70
-
# hit Ctrl-a + c + q to exit the VM
107
+
# type `poweroff` at the shell to exit the VM
71
108
```
72
109
73
110
This starts a knot on port 6000, a spindle on port 6555
74
-
with `ssh` exposed on port 2222. You can push repositories
75
-
to this VM with this ssh config block on your main machine:
111
+
with `ssh` exposed on port 2222.
112
+
113
+
Once the services are running, head to
114
+
http://localhost:3000/knots and hit verify. It should
115
+
verify the ownership of the services instantly if everything
116
+
went smoothly.
117
+
118
+
You can push repositories to this VM with this ssh config
119
+
block on your main machine:
76
120
77
121
```bash
78
122
Host nixos-shell
···
89
133
git push local-dev main
90
134
```
91
135
92
-
## running a spindle
136
+
### running a spindle
93
137
94
-
Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
95
-
The above VM should already be running a spindle on `localhost:6555`.
96
-
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
97
-
and register a spindle with hostname `localhost:6555`. It should instantly
98
-
be verified. You can then configure each repository to use this spindle
99
-
and run CI jobs.
138
+
The above VM should already be running a spindle on
139
+
`localhost:6555`. Head to http://localhost:3000/spindles and
140
+
hit verify. You can then configure each repository to use
141
+
this spindle and run CI jobs.
100
142
101
143
Of interest when debugging spindles:
102
144
···
113
155
# litecli has a nicer REPL interface:
114
156
litecli /var/lib/spindle/spindle.db
115
157
```
158
+
159
+
If for any reason you wish to disable either one of the
160
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161
+
`services.tangled-spindle.enable` (or
162
+
`services.tangled-knot.enable`) to `false`.
+14
-6
docs/knot-hosting.md
+14
-6
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_OWNER` should be set to your
77
+
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
72
78
73
79
```
74
80
KNOT_REPO_SCAN_PATH=/home/git
75
81
KNOT_SERVER_HOSTNAME=knot.example.com
76
82
APPVIEW_ENDPOINT=https://tangled.sh
77
-
KNOT_SERVER_SECRET=secret
83
+
KNOT_SERVER_OWNER=did:plc:foobar
78
84
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
79
85
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
80
86
```
···
122
128
Remember to use Let's Encrypt or similar to procure a certificate for your
123
129
knot domain.
124
130
125
-
You should now have a running knot server! You can finalize your registration by hitting the
126
-
`initialize` button on the [/knots](/knots) page.
131
+
You should now have a running knot server! You can finalize
132
+
your registration by hitting the `verify` button on the
133
+
[/knots](https://tangled.sh/knots) page. This simply creates
134
+
a record on your PDS to announce the existence of the knot.
127
135
128
136
### custom paths
129
137
+35
docs/migrations/knot-1.7.0.md
+35
docs/migrations/knot-1.7.0.md
···
1
+
# Upgrading from v1.7.0
2
+
3
+
After v1.7.0, knot secrets have been deprecated. You no
4
+
longer need a secret from the appview to run a knot. All
5
+
authorized commands to knots are managed via [Inter-Service
6
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
7
+
Knots will be read-only until upgraded.
8
+
9
+
Upgrading is quite easy, in essence:
10
+
11
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
12
+
environment variable entirely
13
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
14
+
your DID. You can find your DID in the
15
+
[settings](https://tangled.sh/settings) page.
16
+
- Restart your knot once you have replaced the environment
17
+
variable
18
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
19
+
hit the "retry" button to verify your knot. This simply
20
+
writes a `sh.tangled.knot` record to your PDS.
21
+
22
+
## Nix
23
+
24
+
If you use the nix module, simply bump the flake to the
25
+
latest revision, and change your config block like so:
26
+
27
+
```diff
28
+
services.tangled-knot = {
29
+
enable = true;
30
+
server = {
31
+
- secretFile = /path/to/secret;
32
+
+ owner = "did:plc:foo";
33
+
};
34
+
};
35
+
```
+140
-41
docs/spindle/pipeline.md
+140
-41
docs/spindle/pipeline.md
···
1
-
# spindle pipeline manifest
1
+
# spindle pipelines
2
+
3
+
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
4
+
5
+
The fields are:
2
6
3
-
Spindle pipelines are defined under the `.tangled/workflows` directory in a
4
-
repo. Generally:
7
+
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
8
+
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
9
+
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
10
+
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
11
+
- [Environment](#environment): An **optional** field that allows you to define environment variables.
12
+
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
5
13
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.
14
+
## Trigger
10
15
11
-
Here's an example that uses all fields:
16
+
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
17
+
18
+
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
19
+
- `push`: The workflow should run every time a commit is pushed to the repository.
20
+
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
+
- `manual`: The workflow can be triggered manually.
22
+
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
+
24
+
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
12
25
13
26
```yaml
14
-
# build_and_test.yaml
15
27
when:
16
-
- event: ["push", "pull_request"]
28
+
- event: ["push", "manual"]
17
29
branch: ["main", "develop"]
18
-
- event: ["manual"]
30
+
- event: ["pull_request"]
31
+
branch: ["main"]
32
+
```
33
+
34
+
## Engine
35
+
36
+
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
37
+
38
+
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
39
+
40
+
Example:
41
+
42
+
```yaml
43
+
engine: "nixery"
44
+
```
45
+
46
+
## Clone options
47
+
48
+
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
49
+
50
+
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
51
+
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
52
+
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
53
+
54
+
The default settings are:
55
+
56
+
```yaml
57
+
clone:
58
+
skip: false
59
+
depth: 1
60
+
submodules: false
61
+
```
62
+
63
+
## Dependencies
64
+
65
+
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
66
+
67
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
19
68
69
+
```yaml
20
70
dependencies:
21
-
## from nixpkgs
71
+
# nixpkgs
22
72
nixpkgs:
23
73
- nodejs
24
-
## custom registry
25
-
git+https://tangled.sh/@oppi.li/statix:
26
-
- statix
74
+
- go
75
+
# custom registry
76
+
git+https://tangled.sh/@example.com/my_pkg:
77
+
- my_pkg
78
+
```
79
+
80
+
Now these dependencies are available to use in your workflow!
81
+
82
+
## Environment
83
+
84
+
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
85
+
86
+
Example:
87
+
88
+
```yaml
89
+
environment:
90
+
GOOS: "linux"
91
+
GOARCH: "arm64"
92
+
NODE_ENV: "production"
93
+
MY_ENV_VAR: "MY_ENV_VALUE"
94
+
```
27
95
28
-
steps:
29
-
- name: "Install dependencies"
30
-
command: "npm install"
31
-
environment:
32
-
NODE_ENV: "development"
33
-
CI: "true"
96
+
## Steps
97
+
98
+
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
99
+
100
+
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
101
+
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
102
+
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
34
103
35
-
- name: "Run linter"
36
-
command: "npm run lint"
104
+
Example:
37
105
38
-
- name: "Run tests"
39
-
command: "npm test"
106
+
```yaml
107
+
steps:
108
+
- name: "Build backend"
109
+
command: "go build"
40
110
environment:
41
-
NODE_ENV: "test"
42
-
JEST_WORKERS: "2"
43
-
44
-
- name: "Build application"
111
+
GOOS: "darwin"
112
+
GOARCH: "arm64"
113
+
- name: "Build frontend"
45
114
command: "npm run build"
46
115
environment:
47
116
NODE_ENV: "production"
117
+
```
48
118
49
-
environment:
50
-
BUILD_NUMBER: "123"
51
-
GIT_BRANCH: "main"
119
+
## Complete workflow
52
120
53
-
## current repository is cloned and checked out at the target ref
54
-
## by default.
121
+
```yaml
122
+
# .tangled/workflows/build.yml
123
+
124
+
when:
125
+
- event: ["push", "manual"]
126
+
branch: ["main", "develop"]
127
+
- event: ["pull_request"]
128
+
branch: ["main"]
129
+
130
+
engine: "nixery"
131
+
132
+
# using the default values
55
133
clone:
56
134
skip: false
57
-
depth: 50
58
-
submodules: true
59
-
```
135
+
depth: 1
136
+
submodules: false
137
+
138
+
dependencies:
139
+
# nixpkgs
140
+
nixpkgs:
141
+
- nodejs
142
+
- go
143
+
# custom registry
144
+
git+https://tangled.sh/@example.com/my_pkg:
145
+
- my_pkg
60
146
61
-
## git push options
147
+
environment:
148
+
GOOS: "linux"
149
+
GOARCH: "arm64"
150
+
NODE_ENV: "production"
151
+
MY_ENV_VAR: "MY_ENV_VALUE"
62
152
63
-
These are push options that can be used with the `--push-option (-o)` flag of git push:
153
+
steps:
154
+
- name: "Build backend"
155
+
command: "go build"
156
+
environment:
157
+
GOOS: "darwin"
158
+
GOARCH: "arm64"
159
+
- name: "Build frontend"
160
+
command: "npm run build"
161
+
environment:
162
+
NODE_ENV: "production"
163
+
```
64
164
65
-
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
66
-
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
165
+
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+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
}
+3
-3
flake.lock
+3
-3
flake.lock
···
26
26
]
27
27
},
28
28
"locked": {
29
-
"lastModified": 1751702058,
30
-
"narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=",
29
+
"lastModified": 1754078208,
30
+
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
31
31
"owner": "nix-community",
32
32
"repo": "gomod2nix",
33
-
"rev": "664ad7a2df4623037e315e4094346bff5c44e9ee",
33
+
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
34
34
"type": "github"
35
35
},
36
36
"original": {
+57
-31
flake.nix
+57
-31
flake.nix
···
106
106
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
107
107
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
108
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
+
};
116
+
117
+
gofmt = {
118
+
command = pkgs.lib.getExe' pkgs.go "gofmt";
119
+
options = ["-w"];
120
+
includes = ["*.go"];
121
+
};
122
+
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
+
};
109
136
});
110
137
defaultPackage = forAllSystems (system: self.packages.${system}.appview);
111
-
formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra);
112
138
devShells = forAllSystems (system: let
113
139
pkgs = nixpkgsFor.${system};
114
140
packages' = self.packages.${system};
···
129
155
pkgs.redis
130
156
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
131
157
packages'.lexgen
158
+
packages'.treefmt-wrapper
132
159
];
133
160
shellHook = ''
134
161
mkdir -p appview/pages/static
135
162
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
-
cp -frv --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
163
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
137
164
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
138
165
'';
139
166
env.CGO_ENABLED = 1;
···
158
185
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
159
186
'';
160
187
in {
188
+
fmt = {
189
+
type = "app";
190
+
program = pkgs.lib.getExe packages'.treefmt-wrapper;
191
+
};
161
192
watch-appview = {
162
193
type = "app";
163
194
program = toString (pkgs.writeShellScript "watch-appview" ''
164
195
echo "copying static files to appview/pages/static..."
165
-
${pkgs.coreutils}/bin/cp -frv --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
196
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
197
${air-watcher "appview" ""}/bin/run
167
198
'');
168
199
};
···
175
206
program = ''${tailwind-watcher}/bin/run'';
176
207
};
177
208
vm = let
178
-
system =
209
+
guestSystem =
179
210
if pkgs.stdenv.hostPlatform.isAarch64
180
-
then "aarch64"
181
-
else "x86_64";
182
-
183
-
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
184
-
patches =
185
-
(old.patches or [])
186
-
++ [
187
-
# https://github.com/Mic92/nixos-shell/pull/94
188
-
(pkgs.fetchpatch {
189
-
name = "fix-foreign-vm.patch";
190
-
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
191
-
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
192
-
})
193
-
];
194
-
});
211
+
then "aarch64-linux"
212
+
else "x86_64-linux";
195
213
in {
196
214
type = "app";
197
-
program = toString (pkgs.writeShellScript "vm" ''
198
-
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
199
-
'');
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;
200
234
};
201
235
gomod2nix = {
202
236
type = "app";
···
218
252
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
219
253
cd "$rootDir"
220
254
221
-
rm api/tangled/*
255
+
rm -f api/tangled/*
222
256
lexgen --build-file lexicon-build-config.json lexicons
223
257
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
224
258
${pkgs.gotools}/bin/goimports -w api/tangled/*
···
257
291
imports = [./nix/modules/spindle.nix];
258
292
259
293
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
260
-
};
261
-
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
262
-
inherit self nixpkgs;
263
-
system = "x86_64-linux";
264
-
};
265
-
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
266
-
inherit self nixpkgs;
267
-
system = "aarch64-linux";
268
294
};
269
295
};
270
296
}
+5
-1
go.mod
+5
-1
go.mod
···
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
24
github.com/google/uuid v1.6.0
25
+
github.com/gorilla/feeds v1.2.0
25
26
github.com/gorilla/sessions v1.4.0
26
27
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
27
28
github.com/hiddeco/sshsig v0.2.0
···
38
39
github.com/stretchr/testify v1.10.0
39
40
github.com/urfave/cli/v3 v3.3.3
40
41
github.com/whyrusleeping/cbor-gen v0.3.1
41
-
github.com/yuin/goldmark v1.4.13
42
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
+
github.com/yuin/goldmark v1.7.12
44
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
42
45
golang.org/x/crypto v0.40.0
43
46
golang.org/x/net v0.42.0
44
47
golang.org/x/sync v0.16.0
···
152
155
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
153
156
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
154
157
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
158
+
github.com/wyatt915/treeblood v0.1.15 // indirect
155
159
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
156
160
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
157
161
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+12
-1
go.sum
+12
-1
go.sum
···
79
79
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
80
80
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
81
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=
82
83
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
83
84
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
84
85
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
···
173
174
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
174
175
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
175
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=
176
179
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
177
180
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
178
181
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
···
423
426
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
424
427
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
425
428
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
429
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
430
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
431
+
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
432
+
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
426
433
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
427
434
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
428
435
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
429
436
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
430
-
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
431
437
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
438
+
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
439
+
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
440
+
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
441
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
442
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
432
443
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
433
444
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
434
445
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+83
-6
input.css
+83
-6
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");
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");
32
48
font-weight: normal;
33
49
font-style: italic;
34
50
font-display: swap;
35
51
}
36
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;
65
+
font-style: italic;
66
+
font-display: swap;
67
+
}
68
+
37
69
::selection {
38
70
@apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white;
39
71
}
···
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
174
margin: 0;
104
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;
105
184
}
106
185
}
107
186
@layer utilities {
···
122
201
/* PreWrapper */
123
202
.chroma {
124
203
color: #4c4f69;
125
-
background-color: #eff1f5;
126
204
}
127
205
/* Error */
128
206
.chroma .err {
···
459
537
/* PreWrapper */
460
538
.chroma {
461
539
color: #cad3f5;
462
-
background-color: #24273a;
463
540
}
464
541
/* Error */
465
542
.chroma .err {
+6
-4
jetstream/jetstream.go
+6
-4
jetstream/jetstream.go
···
68
68
type processor func(context.Context, *models.Event) error
69
69
70
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
71
-
// empty filter => all dids allowed
72
-
if len(j.wantedDids) == 0 {
73
-
return processFunc
74
-
}
75
71
// since this closure references j.WantedDids; it should auto-update
76
72
// existing instances of the closure when j.WantedDids is mutated
77
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
+
78
80
if _, ok := j.wantedDids[evt.Did]; ok {
79
81
return processFunc(ctx, evt)
80
82
} else {
-336
knotclient/signer.go
-336
knotclient/signer.go
···
1
-
package knotclient
2
-
3
-
import (
4
-
"bytes"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
8
-
"encoding/json"
9
-
"fmt"
10
-
"io"
11
-
"log"
12
-
"net/http"
13
-
"net/url"
14
-
"time"
15
-
16
-
"tangled.sh/tangled.sh/core/types"
17
-
)
18
-
19
-
type SignerTransport struct {
20
-
Secret string
21
-
}
22
-
23
-
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
24
-
timestamp := time.Now().Format(time.RFC3339)
25
-
mac := hmac.New(sha256.New, []byte(s.Secret))
26
-
message := req.Method + req.URL.Path + timestamp
27
-
mac.Write([]byte(message))
28
-
signature := hex.EncodeToString(mac.Sum(nil))
29
-
req.Header.Set("X-Signature", signature)
30
-
req.Header.Set("X-Timestamp", timestamp)
31
-
return http.DefaultTransport.RoundTrip(req)
32
-
}
33
-
34
-
type SignedClient struct {
35
-
Secret string
36
-
Url *url.URL
37
-
client *http.Client
38
-
}
39
-
40
-
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
41
-
client := &http.Client{
42
-
Timeout: 5 * time.Second,
43
-
Transport: SignerTransport{
44
-
Secret: secret,
45
-
},
46
-
}
47
-
48
-
scheme := "https"
49
-
if dev {
50
-
scheme = "http"
51
-
}
52
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
53
-
if err != nil {
54
-
return nil, err
55
-
}
56
-
57
-
signedClient := &SignedClient{
58
-
Secret: secret,
59
-
client: client,
60
-
Url: url,
61
-
}
62
-
63
-
return signedClient, nil
64
-
}
65
-
66
-
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
67
-
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
68
-
}
69
-
70
-
func (s *SignedClient) Init(did string) (*http.Response, error) {
71
-
const (
72
-
Method = "POST"
73
-
Endpoint = "/init"
74
-
)
75
-
76
-
body, _ := json.Marshal(map[string]any{
77
-
"did": did,
78
-
})
79
-
80
-
req, err := s.newRequest(Method, Endpoint, body)
81
-
if err != nil {
82
-
return nil, err
83
-
}
84
-
85
-
return s.client.Do(req)
86
-
}
87
-
88
-
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
89
-
const (
90
-
Method = "PUT"
91
-
Endpoint = "/repo/new"
92
-
)
93
-
94
-
body, _ := json.Marshal(map[string]any{
95
-
"did": did,
96
-
"name": repoName,
97
-
"default_branch": defaultBranch,
98
-
})
99
-
100
-
req, err := s.newRequest(Method, Endpoint, body)
101
-
if err != nil {
102
-
return nil, err
103
-
}
104
-
105
-
return s.client.Do(req)
106
-
}
107
-
108
-
func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
109
-
const (
110
-
Method = "GET"
111
-
)
112
-
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
113
-
114
-
req, err := s.newRequest(Method, endpoint, nil)
115
-
if err != nil {
116
-
return nil, err
117
-
}
118
-
119
-
resp, err := s.client.Do(req)
120
-
if err != nil {
121
-
return nil, err
122
-
}
123
-
124
-
var result types.RepoLanguageResponse
125
-
if resp.StatusCode != http.StatusOK {
126
-
log.Println("failed to calculate languages", resp.Status)
127
-
return &types.RepoLanguageResponse{}, nil
128
-
}
129
-
130
-
body, err := io.ReadAll(resp.Body)
131
-
if err != nil {
132
-
return nil, err
133
-
}
134
-
135
-
err = json.Unmarshal(body, &result)
136
-
if err != nil {
137
-
return nil, err
138
-
}
139
-
140
-
return &result, nil
141
-
}
142
-
143
-
func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) {
144
-
const (
145
-
Method = "GET"
146
-
)
147
-
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
148
-
149
-
body, _ := json.Marshal(map[string]any{
150
-
"did": ownerDid,
151
-
"source": source,
152
-
"name": name,
153
-
"hiddenref": hiddenRef,
154
-
})
155
-
156
-
req, err := s.newRequest(Method, endpoint, body)
157
-
if err != nil {
158
-
return nil, err
159
-
}
160
-
161
-
return s.client.Do(req)
162
-
}
163
-
164
-
func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) {
165
-
const (
166
-
Method = "POST"
167
-
)
168
-
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
169
-
170
-
body, _ := json.Marshal(map[string]any{
171
-
"did": ownerDid,
172
-
"source": source,
173
-
"name": name,
174
-
})
175
-
176
-
req, err := s.newRequest(Method, endpoint, body)
177
-
if err != nil {
178
-
return nil, err
179
-
}
180
-
181
-
return s.client.Do(req)
182
-
}
183
-
184
-
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
185
-
const (
186
-
Method = "POST"
187
-
Endpoint = "/repo/fork"
188
-
)
189
-
190
-
body, _ := json.Marshal(map[string]any{
191
-
"did": ownerDid,
192
-
"source": source,
193
-
"name": name,
194
-
})
195
-
196
-
req, err := s.newRequest(Method, Endpoint, body)
197
-
if err != nil {
198
-
return nil, err
199
-
}
200
-
201
-
return s.client.Do(req)
202
-
}
203
-
204
-
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
205
-
const (
206
-
Method = "DELETE"
207
-
Endpoint = "/repo"
208
-
)
209
-
210
-
body, _ := json.Marshal(map[string]any{
211
-
"did": did,
212
-
"name": repoName,
213
-
})
214
-
215
-
req, err := s.newRequest(Method, Endpoint, body)
216
-
if err != nil {
217
-
return nil, err
218
-
}
219
-
220
-
return s.client.Do(req)
221
-
}
222
-
223
-
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
224
-
const (
225
-
Method = "PUT"
226
-
Endpoint = "/member/add"
227
-
)
228
-
229
-
body, _ := json.Marshal(map[string]any{
230
-
"did": did,
231
-
})
232
-
233
-
req, err := s.newRequest(Method, Endpoint, body)
234
-
if err != nil {
235
-
return nil, err
236
-
}
237
-
238
-
return s.client.Do(req)
239
-
}
240
-
241
-
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
242
-
const (
243
-
Method = "PUT"
244
-
)
245
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
246
-
247
-
body, _ := json.Marshal(map[string]any{
248
-
"branch": branch,
249
-
})
250
-
251
-
req, err := s.newRequest(Method, endpoint, body)
252
-
if err != nil {
253
-
return nil, err
254
-
}
255
-
256
-
return s.client.Do(req)
257
-
}
258
-
259
-
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
260
-
const (
261
-
Method = "POST"
262
-
)
263
-
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
264
-
265
-
body, _ := json.Marshal(map[string]any{
266
-
"did": memberDid,
267
-
})
268
-
269
-
req, err := s.newRequest(Method, endpoint, body)
270
-
if err != nil {
271
-
return nil, err
272
-
}
273
-
274
-
return s.client.Do(req)
275
-
}
276
-
277
-
func (s *SignedClient) Merge(
278
-
patch []byte,
279
-
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
280
-
) (*http.Response, error) {
281
-
const (
282
-
Method = "POST"
283
-
)
284
-
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
285
-
286
-
mr := types.MergeRequest{
287
-
Branch: branch,
288
-
CommitMessage: commitMessage,
289
-
CommitBody: commitBody,
290
-
AuthorName: authorName,
291
-
AuthorEmail: authorEmail,
292
-
Patch: string(patch),
293
-
}
294
-
295
-
body, _ := json.Marshal(mr)
296
-
297
-
req, err := s.newRequest(Method, endpoint, body)
298
-
if err != nil {
299
-
return nil, err
300
-
}
301
-
302
-
return s.client.Do(req)
303
-
}
304
-
305
-
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
306
-
const (
307
-
Method = "POST"
308
-
)
309
-
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
310
-
311
-
body, _ := json.Marshal(map[string]any{
312
-
"patch": string(patch),
313
-
"branch": branch,
314
-
})
315
-
316
-
req, err := s.newRequest(Method, endpoint, body)
317
-
if err != nil {
318
-
return nil, err
319
-
}
320
-
321
-
return s.client.Do(req)
322
-
}
323
-
324
-
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
325
-
const (
326
-
Method = "POST"
327
-
)
328
-
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
329
-
330
-
req, err := s.newRequest(Method, endpoint, nil)
331
-
if err != nil {
332
-
return nil, err
333
-
}
334
-
335
-
return s.client.Do(req)
336
-
}
+35
knotclient/unsigned.go
+35
knotclient/unsigned.go
···
248
248
249
249
return &formatPatchResponse, nil
250
250
}
251
+
252
+
func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
253
+
const (
254
+
Method = "GET"
255
+
)
256
+
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
257
+
258
+
req, err := s.newRequest(Method, endpoint, nil, nil)
259
+
if err != nil {
260
+
return nil, err
261
+
}
262
+
263
+
resp, err := s.client.Do(req)
264
+
if err != nil {
265
+
return nil, err
266
+
}
267
+
268
+
var result types.RepoLanguageResponse
269
+
if resp.StatusCode != http.StatusOK {
270
+
log.Println("failed to calculate languages", resp.Status)
271
+
return &types.RepoLanguageResponse{}, nil
272
+
}
273
+
274
+
body, err := io.ReadAll(resp.Body)
275
+
if err != nil {
276
+
return nil, err
277
+
}
278
+
279
+
err = json.Unmarshal(body, &result)
280
+
if err != nil {
281
+
return nil, err
282
+
}
283
+
284
+
return &result, nil
285
+
}
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
···
17
17
type Server struct {
18
18
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"`
19
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
20
-
Secret string `env:"SECRET, required"`
21
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
22
21
Hostname string `env:"HOSTNAME, required"`
23
22
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
+
Owner string `env:"OWNER, required"`
24
24
LogDids bool `env:"LOG_DIDS, default=true"`
25
25
26
26
// This disables signature verification so use with caution.
+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
-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
+28
-22
knotserver/git/post_receive.go
+28
-22
knotserver/git/post_receive.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"strings"
···
57
58
ByEmail map[string]int
58
59
}
59
60
60
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
62
+
var errs error
63
+
61
64
commitCount, err := g.newCommitCount(line)
62
-
if err != nil {
63
-
// TODO: log this
64
-
}
65
+
errors.Join(errs, err)
65
66
66
67
isDefaultRef, err := g.isDefaultBranch(line)
67
-
if err != nil {
68
-
// TODO: log this
69
-
}
68
+
errors.Join(errs, err)
70
69
71
70
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
72
71
defer cancel()
73
72
breakdown, err := g.AnalyzeLanguages(ctx)
74
-
if err != nil {
75
-
// TODO: log this
76
-
}
73
+
errors.Join(errs, err)
77
74
78
75
return RefUpdateMeta{
79
76
CommitCount: commitCount,
80
77
IsDefaultRef: isDefaultRef,
81
78
LangBreakdown: breakdown,
82
-
}
79
+
}, errs
83
80
}
84
81
85
82
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
95
92
args := []string{fmt.Sprintf("--max-count=%d", 100)}
96
93
97
94
if line.OldSha.IsZero() {
98
-
// just git rev-list <newsha>
95
+
// git rev-list <newsha> ^other-branches --not ^this-branch
99
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
+
}
103
+
}
104
+
105
+
args = append(args, "--not")
106
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
100
107
} else {
101
108
// git rev-list <oldsha>..<newsha>
102
109
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
···
138
145
}
139
146
140
147
func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta {
141
-
var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem
148
+
var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount
142
149
for e, v := range m.CommitCount.ByEmail {
143
-
byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{
150
+
byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{
144
151
Email: e,
145
152
Count: int64(v),
146
153
})
147
154
}
148
155
149
-
var langs []*tangled.GitRefUpdate_Pair
156
+
var langs []*tangled.GitRefUpdate_IndividualLanguageSize
150
157
for lang, size := range m.LangBreakdown {
151
-
langs = append(langs, &tangled.GitRefUpdate_Pair{
158
+
langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{
152
159
Lang: lang,
153
160
Size: size,
154
161
})
155
162
}
156
-
langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{
157
-
Inputs: langs,
158
-
}
159
163
160
164
return tangled.GitRefUpdate_Meta{
161
-
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
165
+
CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{
162
166
ByEmail: byEmail,
163
167
},
164
-
IsDefaultRef: m.IsDefaultRef,
165
-
LangBreakdown: langBreakdown,
168
+
IsDefaultRef: m.IsDefaultRef,
169
+
LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{
170
+
Inputs: langs,
171
+
},
166
172
}
167
173
}
+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)
+1008
-150
knotserver/handler.go
+1008
-150
knotserver/handler.go
···
1
1
package knotserver
2
2
3
3
import (
4
+
"compress/gzip"
4
5
"context"
6
+
"crypto/sha256"
7
+
"encoding/json"
8
+
"errors"
5
9
"fmt"
6
-
"log/slog"
10
+
"log"
7
11
"net/http"
8
-
"runtime/debug"
12
+
"net/url"
13
+
"path/filepath"
14
+
"strconv"
15
+
"strings"
16
+
"sync"
17
+
"time"
9
18
19
+
securejoin "github.com/cyphar/filepath-securejoin"
20
+
"github.com/gliderlabs/ssh"
10
21
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
"tangled.sh/tangled.sh/core/jetstream"
13
-
"tangled.sh/tangled.sh/core/knotserver/config"
22
+
"github.com/go-git/go-git/v5/plumbing"
23
+
"github.com/go-git/go-git/v5/plumbing/object"
14
24
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
-
tlog "tangled.sh/tangled.sh/core/log"
17
-
"tangled.sh/tangled.sh/core/notifier"
18
-
"tangled.sh/tangled.sh/core/rbac"
25
+
"tangled.sh/tangled.sh/core/knotserver/git"
26
+
"tangled.sh/tangled.sh/core/types"
19
27
)
20
28
21
-
type Handle struct {
22
-
c *config.Config
23
-
db *db.DB
24
-
jc *jetstream.JetstreamClient
25
-
e *rbac.Enforcer
26
-
l *slog.Logger
27
-
n *notifier.Notifier
28
-
resolver *idresolver.Resolver
29
+
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
30
+
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
31
+
}
32
+
33
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
34
+
w.Header().Set("Content-Type", "application/json")
35
+
36
+
capabilities := map[string]any{
37
+
"pull_requests": map[string]any{
38
+
"format_patch": true,
39
+
"patch_submissions": true,
40
+
"branch_submissions": true,
41
+
"fork_submissions": true,
42
+
},
43
+
"xrpc": true,
44
+
}
29
45
30
-
// init is a channel that is closed when the knot has been initailized
31
-
// i.e. when the first user (knot owner) has been added.
32
-
init chan struct{}
33
-
knotInitialized bool
46
+
jsonData, err := json.Marshal(capabilities)
47
+
if err != nil {
48
+
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
49
+
return
50
+
}
51
+
52
+
w.Write(jsonData)
34
53
}
35
54
36
-
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
37
-
r := chi.NewRouter()
55
+
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
56
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
57
+
l := h.l.With("path", path, "handler", "RepoIndex")
58
+
ref := chi.URLParam(r, "ref")
59
+
ref, _ = url.PathUnescape(ref)
60
+
61
+
gr, err := git.Open(path, ref)
62
+
if err != nil {
63
+
plain, err2 := git.PlainOpen(path)
64
+
if err2 != nil {
65
+
l.Error("opening repo", "error", err2.Error())
66
+
notFound(w)
67
+
return
68
+
}
69
+
branches, _ := plain.Branches()
38
70
39
-
h := Handle{
40
-
c: c,
41
-
db: db,
42
-
e: e,
43
-
l: l,
44
-
jc: jc,
45
-
n: n,
46
-
resolver: idresolver.DefaultResolver(),
47
-
init: make(chan struct{}),
71
+
log.Println(err)
72
+
73
+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
74
+
resp := types.RepoIndexResponse{
75
+
IsEmpty: true,
76
+
Branches: branches,
77
+
}
78
+
writeJSON(w, resp)
79
+
return
80
+
} else {
81
+
l.Error("opening repo", "error", err.Error())
82
+
notFound(w)
83
+
return
84
+
}
48
85
}
49
86
50
-
err := e.AddKnot(rbac.ThisServer)
87
+
var (
88
+
commits []*object.Commit
89
+
total int
90
+
branches []types.Branch
91
+
files []types.NiceTree
92
+
tags []object.Tag
93
+
)
94
+
95
+
var wg sync.WaitGroup
96
+
errorsCh := make(chan error, 5)
97
+
98
+
wg.Add(1)
99
+
go func() {
100
+
defer wg.Done()
101
+
cs, err := gr.Commits(0, 60)
102
+
if err != nil {
103
+
errorsCh <- fmt.Errorf("commits: %w", err)
104
+
return
105
+
}
106
+
commits = cs
107
+
}()
108
+
109
+
wg.Add(1)
110
+
go func() {
111
+
defer wg.Done()
112
+
t, err := gr.TotalCommits()
113
+
if err != nil {
114
+
errorsCh <- fmt.Errorf("calculating total: %w", err)
115
+
return
116
+
}
117
+
total = t
118
+
}()
119
+
120
+
wg.Add(1)
121
+
go func() {
122
+
defer wg.Done()
123
+
bs, err := gr.Branches()
124
+
if err != nil {
125
+
errorsCh <- fmt.Errorf("fetching branches: %w", err)
126
+
return
127
+
}
128
+
branches = bs
129
+
}()
130
+
131
+
wg.Add(1)
132
+
go func() {
133
+
defer wg.Done()
134
+
ts, err := gr.Tags()
135
+
if err != nil {
136
+
errorsCh <- fmt.Errorf("fetching tags: %w", err)
137
+
return
138
+
}
139
+
tags = ts
140
+
}()
141
+
142
+
wg.Add(1)
143
+
go func() {
144
+
defer wg.Done()
145
+
fs, err := gr.FileTree(r.Context(), "")
146
+
if err != nil {
147
+
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
148
+
return
149
+
}
150
+
files = fs
151
+
}()
152
+
153
+
wg.Wait()
154
+
close(errorsCh)
155
+
156
+
// show any errors
157
+
for err := range errorsCh {
158
+
l.Error("loading repo", "error", err.Error())
159
+
writeError(w, err.Error(), http.StatusInternalServerError)
160
+
return
161
+
}
162
+
163
+
rtags := []*types.TagReference{}
164
+
for _, tag := range tags {
165
+
var target *object.Tag
166
+
if tag.Target != plumbing.ZeroHash {
167
+
target = &tag
168
+
}
169
+
tr := types.TagReference{
170
+
Tag: target,
171
+
}
172
+
173
+
tr.Reference = types.Reference{
174
+
Name: tag.Name,
175
+
Hash: tag.Hash.String(),
176
+
}
177
+
178
+
if tag.Message != "" {
179
+
tr.Message = tag.Message
180
+
}
181
+
182
+
rtags = append(rtags, &tr)
183
+
}
184
+
185
+
var readmeContent string
186
+
var readmeFile string
187
+
for _, readme := range h.c.Repo.Readme {
188
+
content, _ := gr.FileContent(readme)
189
+
if len(content) > 0 {
190
+
readmeContent = string(content)
191
+
readmeFile = readme
192
+
}
193
+
}
194
+
195
+
if ref == "" {
196
+
mainBranch, err := gr.FindMainBranch()
197
+
if err != nil {
198
+
writeError(w, err.Error(), http.StatusInternalServerError)
199
+
l.Error("finding main branch", "error", err.Error())
200
+
return
201
+
}
202
+
ref = mainBranch
203
+
}
204
+
205
+
resp := types.RepoIndexResponse{
206
+
IsEmpty: false,
207
+
Ref: ref,
208
+
Commits: commits,
209
+
Description: getDescription(path),
210
+
Readme: readmeContent,
211
+
ReadmeFileName: readmeFile,
212
+
Files: files,
213
+
Branches: branches,
214
+
Tags: rtags,
215
+
TotalCommits: total,
216
+
}
217
+
218
+
writeJSON(w, resp)
219
+
}
220
+
221
+
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
222
+
treePath := chi.URLParam(r, "*")
223
+
ref := chi.URLParam(r, "ref")
224
+
ref, _ = url.PathUnescape(ref)
225
+
226
+
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
227
+
228
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
229
+
gr, err := git.Open(path, ref)
51
230
if err != nil {
52
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
231
+
notFound(w)
232
+
return
53
233
}
54
234
55
-
err = h.jc.StartJetstream(ctx, h.processMessages)
235
+
files, err := gr.FileTree(r.Context(), treePath)
56
236
if err != nil {
57
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
237
+
writeError(w, err.Error(), http.StatusInternalServerError)
238
+
l.Error("file tree", "error", err.Error())
239
+
return
58
240
}
59
241
60
-
// Check if the knot knows about any Dids;
61
-
// if it does, it is already initialized and we can repopulate the
62
-
// Jetstream subscriptions.
63
-
dids, err := db.GetAllDids()
242
+
resp := types.RepoTreeResponse{
243
+
Ref: ref,
244
+
Parent: treePath,
245
+
Description: getDescription(path),
246
+
DotDot: filepath.Dir(treePath),
247
+
Files: files,
248
+
}
249
+
250
+
writeJSON(w, resp)
251
+
}
252
+
253
+
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
254
+
treePath := chi.URLParam(r, "*")
255
+
ref := chi.URLParam(r, "ref")
256
+
ref, _ = url.PathUnescape(ref)
257
+
258
+
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
259
+
260
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
261
+
gr, err := git.Open(path, ref)
64
262
if err != nil {
65
-
return nil, fmt.Errorf("failed to get all Dids: %w", err)
263
+
notFound(w)
264
+
return
265
+
}
266
+
267
+
contents, err := gr.RawContent(treePath)
268
+
if err != nil {
269
+
writeError(w, err.Error(), http.StatusBadRequest)
270
+
l.Error("file content", "error", err.Error())
271
+
return
272
+
}
273
+
274
+
mimeType := http.DetectContentType(contents)
275
+
276
+
// exception for svg
277
+
if filepath.Ext(treePath) == ".svg" {
278
+
mimeType = "image/svg+xml"
66
279
}
67
280
68
-
if len(dids) > 0 {
69
-
h.knotInitialized = true
70
-
close(h.init)
71
-
for _, d := range dids {
72
-
h.jc.AddDid(d)
281
+
contentHash := sha256.Sum256(contents)
282
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
283
+
284
+
// allow image, video, and text/plain files to be served directly
285
+
switch {
286
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
287
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
288
+
w.WriteHeader(http.StatusNotModified)
289
+
return
73
290
}
291
+
w.Header().Set("ETag", eTag)
292
+
293
+
case strings.HasPrefix(mimeType, "text/plain"):
294
+
w.Header().Set("Cache-Control", "public, no-cache")
295
+
296
+
default:
297
+
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
298
+
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
299
+
return
74
300
}
75
301
76
-
r.Get("/", h.Index)
77
-
r.Get("/capabilities", h.Capabilities)
78
-
r.Get("/version", h.Version)
79
-
r.Route("/{did}", func(r chi.Router) {
80
-
// Repo routes
81
-
r.Route("/{name}", func(r chi.Router) {
82
-
r.Route("/collaborator", func(r chi.Router) {
83
-
r.Use(h.VerifySignature)
84
-
r.Post("/add", h.AddRepoCollaborator)
85
-
})
302
+
w.Header().Set("Content-Type", mimeType)
303
+
w.Write(contents)
304
+
}
305
+
306
+
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
307
+
treePath := chi.URLParam(r, "*")
308
+
ref := chi.URLParam(r, "ref")
309
+
ref, _ = url.PathUnescape(ref)
310
+
311
+
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
312
+
313
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
314
+
gr, err := git.Open(path, ref)
315
+
if err != nil {
316
+
notFound(w)
317
+
return
318
+
}
319
+
320
+
var isBinaryFile bool = false
321
+
contents, err := gr.FileContent(treePath)
322
+
if errors.Is(err, git.ErrBinaryFile) {
323
+
isBinaryFile = true
324
+
} else if errors.Is(err, object.ErrFileNotFound) {
325
+
notFound(w)
326
+
return
327
+
} else if err != nil {
328
+
writeError(w, err.Error(), http.StatusInternalServerError)
329
+
return
330
+
}
331
+
332
+
bytes := []byte(contents)
333
+
// safe := string(sanitize(bytes))
334
+
sizeHint := len(bytes)
335
+
336
+
resp := types.RepoBlobResponse{
337
+
Ref: ref,
338
+
Contents: string(bytes),
339
+
Path: treePath,
340
+
IsBinary: isBinaryFile,
341
+
SizeHint: uint64(sizeHint),
342
+
}
343
+
344
+
h.showFile(resp, w, l)
345
+
}
346
+
347
+
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
348
+
name := chi.URLParam(r, "name")
349
+
file := chi.URLParam(r, "file")
86
350
87
-
r.Route("/languages", func(r chi.Router) {
88
-
r.With(h.VerifySignature)
89
-
r.Get("/", h.RepoLanguages)
90
-
r.Get("/{ref}", h.RepoLanguages)
91
-
})
351
+
l := h.l.With("handler", "Archive", "name", name, "file", file)
92
352
93
-
r.Get("/", h.RepoIndex)
94
-
r.Get("/info/refs", h.InfoRefs)
95
-
r.Post("/git-upload-pack", h.UploadPack)
96
-
r.Post("/git-receive-pack", h.ReceivePack)
97
-
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
353
+
// TODO: extend this to add more files compression (e.g.: xz)
354
+
if !strings.HasSuffix(file, ".tar.gz") {
355
+
notFound(w)
356
+
return
357
+
}
98
358
99
-
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
359
+
ref := strings.TrimSuffix(file, ".tar.gz")
100
360
101
-
r.Route("/merge", func(r chi.Router) {
102
-
r.With(h.VerifySignature)
103
-
r.Post("/", h.Merge)
104
-
r.Post("/check", h.MergeCheck)
105
-
})
361
+
unescapedRef, err := url.PathUnescape(ref)
362
+
if err != nil {
363
+
notFound(w)
364
+
return
365
+
}
366
+
367
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
368
+
369
+
// This allows the browser to use a proper name for the file when
370
+
// downloading
371
+
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
372
+
setContentDisposition(w, filename)
373
+
setGZipMIME(w)
374
+
375
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
376
+
gr, err := git.Open(path, unescapedRef)
377
+
if err != nil {
378
+
notFound(w)
379
+
return
380
+
}
381
+
382
+
gw := gzip.NewWriter(w)
383
+
defer gw.Close()
384
+
385
+
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
386
+
err = gr.WriteTar(gw, prefix)
387
+
if err != nil {
388
+
// once we start writing to the body we can't report error anymore
389
+
// so we are only left with printing the error.
390
+
l.Error("writing tar file", "error", err.Error())
391
+
return
392
+
}
393
+
394
+
err = gw.Flush()
395
+
if err != nil {
396
+
// once we start writing to the body we can't report error anymore
397
+
// so we are only left with printing the error.
398
+
l.Error("flushing?", "error", err.Error())
399
+
return
400
+
}
401
+
}
106
402
107
-
r.Route("/tree/{ref}", func(r chi.Router) {
108
-
r.Get("/", h.RepoIndex)
109
-
r.Get("/*", h.RepoTree)
110
-
})
403
+
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
404
+
ref := chi.URLParam(r, "ref")
405
+
ref, _ = url.PathUnescape(ref)
111
406
112
-
r.Route("/blob/{ref}", func(r chi.Router) {
113
-
r.Get("/*", h.Blob)
114
-
})
407
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
115
408
116
-
r.Route("/raw/{ref}", func(r chi.Router) {
117
-
r.Get("/*", h.BlobRaw)
118
-
})
409
+
l := h.l.With("handler", "Log", "ref", ref, "path", path)
119
410
120
-
r.Get("/log/{ref}", h.Log)
121
-
r.Get("/archive/{file}", h.Archive)
122
-
r.Get("/commit/{ref}", h.Diff)
123
-
r.Get("/tags", h.Tags)
124
-
r.Route("/branches", func(r chi.Router) {
125
-
r.Get("/", h.Branches)
126
-
r.Get("/{branch}", h.Branch)
127
-
r.Route("/default", func(r chi.Router) {
128
-
r.Get("/", h.DefaultBranch)
129
-
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
130
-
})
131
-
})
132
-
})
133
-
})
411
+
gr, err := git.Open(path, ref)
412
+
if err != nil {
413
+
notFound(w)
414
+
return
415
+
}
134
416
135
-
// xrpc apis
136
-
r.Mount("/xrpc", h.XrpcRouter())
417
+
// Get page parameters
418
+
page := 1
419
+
pageSize := 30
137
420
138
-
// Create a new repository.
139
-
r.Route("/repo", func(r chi.Router) {
140
-
r.Use(h.VerifySignature)
141
-
r.Put("/new", h.NewRepo)
142
-
r.Delete("/", h.RemoveRepo)
143
-
r.Route("/fork", func(r chi.Router) {
144
-
r.Post("/", h.RepoFork)
145
-
r.Post("/sync/{branch}", h.RepoForkSync)
146
-
r.Get("/sync/{branch}", h.RepoForkAheadBehind)
147
-
})
148
-
})
421
+
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
422
+
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
423
+
page = p
424
+
}
425
+
}
149
426
150
-
r.Route("/member", func(r chi.Router) {
151
-
r.Use(h.VerifySignature)
152
-
r.Put("/add", h.AddMember)
153
-
})
427
+
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
428
+
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
429
+
pageSize = ps
430
+
}
431
+
}
154
432
155
-
// Socket that streams git oplogs
156
-
r.Get("/events", h.Events)
433
+
// convert to offset/limit
434
+
offset := (page - 1) * pageSize
435
+
limit := pageSize
157
436
158
-
// Initialize the knot with an owner and public key.
159
-
r.With(h.VerifySignature).Post("/init", h.Init)
437
+
commits, err := gr.Commits(offset, limit)
438
+
if err != nil {
439
+
writeError(w, err.Error(), http.StatusInternalServerError)
440
+
l.Error("fetching commits", "error", err.Error())
441
+
return
442
+
}
160
443
161
-
// Health check. Used for two-way verification with appview.
162
-
r.With(h.VerifySignature).Get("/health", h.Health)
444
+
total := len(commits)
163
445
164
-
// All public keys on the knot.
165
-
r.Get("/keys", h.Keys)
446
+
resp := types.RepoLogResponse{
447
+
Commits: commits,
448
+
Ref: ref,
449
+
Description: getDescription(path),
450
+
Log: true,
451
+
Total: total,
452
+
Page: page,
453
+
PerPage: pageSize,
454
+
}
166
455
167
-
return r, nil
456
+
writeJSON(w, resp)
168
457
}
169
458
170
-
func (h *Handle) XrpcRouter() http.Handler {
171
-
logger := tlog.New("knots")
459
+
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
460
+
ref := chi.URLParam(r, "ref")
461
+
ref, _ = url.PathUnescape(ref)
172
462
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,
463
+
l := h.l.With("handler", "Diff", "ref", ref)
464
+
465
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
466
+
gr, err := git.Open(path, ref)
467
+
if err != nil {
468
+
notFound(w)
469
+
return
181
470
}
182
-
return xrpc.Router()
471
+
472
+
diff, err := gr.Diff()
473
+
if err != nil {
474
+
writeError(w, err.Error(), http.StatusInternalServerError)
475
+
l.Error("getting diff", "error", err.Error())
476
+
return
477
+
}
478
+
479
+
resp := types.RepoCommitResponse{
480
+
Ref: ref,
481
+
Diff: diff,
482
+
}
483
+
484
+
writeJSON(w, resp)
183
485
}
184
486
185
-
// version is set during build time.
186
-
var version string
487
+
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
488
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
489
+
l := h.l.With("handler", "Refs")
187
490
188
-
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
189
-
if version == "" {
190
-
info, ok := debug.ReadBuildInfo()
191
-
if !ok {
192
-
http.Error(w, "failed to read build info", http.StatusInternalServerError)
491
+
gr, err := git.Open(path, "")
492
+
if err != nil {
493
+
notFound(w)
494
+
return
495
+
}
496
+
497
+
tags, err := gr.Tags()
498
+
if err != nil {
499
+
// Non-fatal, we *should* have at least one branch to show.
500
+
l.Warn("getting tags", "error", err.Error())
501
+
}
502
+
503
+
rtags := []*types.TagReference{}
504
+
for _, tag := range tags {
505
+
var target *object.Tag
506
+
if tag.Target != plumbing.ZeroHash {
507
+
target = &tag
508
+
}
509
+
tr := types.TagReference{
510
+
Tag: target,
511
+
}
512
+
513
+
tr.Reference = types.Reference{
514
+
Name: tag.Name,
515
+
Hash: tag.Hash.String(),
516
+
}
517
+
518
+
if tag.Message != "" {
519
+
tr.Message = tag.Message
520
+
}
521
+
522
+
rtags = append(rtags, &tr)
523
+
}
524
+
525
+
resp := types.RepoTagsResponse{
526
+
Tags: rtags,
527
+
}
528
+
529
+
writeJSON(w, resp)
530
+
}
531
+
532
+
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
533
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
534
+
535
+
gr, err := git.PlainOpen(path)
536
+
if err != nil {
537
+
notFound(w)
538
+
return
539
+
}
540
+
541
+
branches, _ := gr.Branches()
542
+
543
+
resp := types.RepoBranchesResponse{
544
+
Branches: branches,
545
+
}
546
+
547
+
writeJSON(w, resp)
548
+
}
549
+
550
+
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
551
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
552
+
branchName := chi.URLParam(r, "branch")
553
+
branchName, _ = url.PathUnescape(branchName)
554
+
555
+
l := h.l.With("handler", "Branch")
556
+
557
+
gr, err := git.PlainOpen(path)
558
+
if err != nil {
559
+
notFound(w)
560
+
return
561
+
}
562
+
563
+
ref, err := gr.Branch(branchName)
564
+
if err != nil {
565
+
l.Error("getting branch", "error", err.Error())
566
+
writeError(w, err.Error(), http.StatusInternalServerError)
567
+
return
568
+
}
569
+
570
+
commit, err := gr.Commit(ref.Hash())
571
+
if err != nil {
572
+
l.Error("getting commit object", "error", err.Error())
573
+
writeError(w, err.Error(), http.StatusInternalServerError)
574
+
return
575
+
}
576
+
577
+
defaultBranch, err := gr.FindMainBranch()
578
+
isDefault := false
579
+
if err != nil {
580
+
l.Error("getting default branch", "error", err.Error())
581
+
// do not quit though
582
+
} else if defaultBranch == branchName {
583
+
isDefault = true
584
+
}
585
+
586
+
resp := types.RepoBranchResponse{
587
+
Branch: types.Branch{
588
+
Reference: types.Reference{
589
+
Name: ref.Name().Short(),
590
+
Hash: ref.Hash().String(),
591
+
},
592
+
Commit: commit,
593
+
IsDefault: isDefault,
594
+
},
595
+
}
596
+
597
+
writeJSON(w, resp)
598
+
}
599
+
600
+
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
601
+
l := h.l.With("handler", "Keys")
602
+
603
+
switch r.Method {
604
+
case http.MethodGet:
605
+
keys, err := h.db.GetAllPublicKeys()
606
+
if err != nil {
607
+
writeError(w, err.Error(), http.StatusInternalServerError)
608
+
l.Error("getting public keys", "error", err.Error())
193
609
return
194
610
}
195
611
196
-
var modVer string
197
-
for _, mod := range info.Deps {
198
-
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
199
-
version = mod.Version
200
-
break
201
-
}
612
+
data := make([]map[string]any, 0)
613
+
for _, key := range keys {
614
+
j := key.JSON()
615
+
data = append(data, j)
202
616
}
617
+
writeJSON(w, data)
618
+
return
203
619
204
-
if modVer == "" {
205
-
version = "unknown"
620
+
case http.MethodPut:
621
+
pk := db.PublicKey{}
622
+
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
623
+
writeError(w, "invalid request body", http.StatusBadRequest)
624
+
return
206
625
}
626
+
627
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
628
+
if err != nil {
629
+
writeError(w, "invalid pubkey", http.StatusBadRequest)
630
+
}
631
+
632
+
if err := h.db.AddPublicKey(pk); err != nil {
633
+
writeError(w, err.Error(), http.StatusInternalServerError)
634
+
l.Error("adding public key", "error", err.Error())
635
+
return
636
+
}
637
+
638
+
w.WriteHeader(http.StatusNoContent)
639
+
return
640
+
}
641
+
}
642
+
643
+
// func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
644
+
// l := h.l.With("handler", "RepoForkSync")
645
+
//
646
+
// data := struct {
647
+
// Did string `json:"did"`
648
+
// Source string `json:"source"`
649
+
// Name string `json:"name,omitempty"`
650
+
// HiddenRef string `json:"hiddenref"`
651
+
// }{}
652
+
//
653
+
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
654
+
// writeError(w, "invalid request body", http.StatusBadRequest)
655
+
// return
656
+
// }
657
+
//
658
+
// did := data.Did
659
+
// source := data.Source
660
+
//
661
+
// if did == "" || source == "" {
662
+
// l.Error("invalid request body, empty did or name")
663
+
// w.WriteHeader(http.StatusBadRequest)
664
+
// return
665
+
// }
666
+
//
667
+
// var name string
668
+
// if data.Name != "" {
669
+
// name = data.Name
670
+
// } else {
671
+
// name = filepath.Base(source)
672
+
// }
673
+
//
674
+
// branch := chi.URLParam(r, "branch")
675
+
// branch, _ = url.PathUnescape(branch)
676
+
//
677
+
// relativeRepoPath := filepath.Join(did, name)
678
+
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
679
+
//
680
+
// gr, err := git.PlainOpen(repoPath)
681
+
// if err != nil {
682
+
// log.Println(err)
683
+
// notFound(w)
684
+
// return
685
+
// }
686
+
//
687
+
// forkCommit, err := gr.ResolveRevision(branch)
688
+
// if err != nil {
689
+
// l.Error("error resolving ref revision", "msg", err.Error())
690
+
// writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
691
+
// return
692
+
// }
693
+
//
694
+
// sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
695
+
// if err != nil {
696
+
// l.Error("error resolving hidden ref revision", "msg", err.Error())
697
+
// writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
698
+
// return
699
+
// }
700
+
//
701
+
// status := types.UpToDate
702
+
// if forkCommit.Hash.String() != sourceCommit.Hash.String() {
703
+
// isAncestor, err := forkCommit.IsAncestor(sourceCommit)
704
+
// if err != nil {
705
+
// log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
706
+
// return
707
+
// }
708
+
//
709
+
// if isAncestor {
710
+
// status = types.FastForwardable
711
+
// } else {
712
+
// status = types.Conflict
713
+
// }
714
+
// }
715
+
//
716
+
// w.Header().Set("Content-Type", "application/json")
717
+
// json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
718
+
// }
719
+
720
+
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
721
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
722
+
ref := chi.URLParam(r, "ref")
723
+
ref, _ = url.PathUnescape(ref)
724
+
725
+
l := h.l.With("handler", "RepoLanguages")
726
+
727
+
gr, err := git.Open(repoPath, ref)
728
+
if err != nil {
729
+
l.Error("opening repo", "error", err.Error())
730
+
notFound(w)
731
+
return
207
732
}
208
733
209
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
210
-
fmt.Fprintf(w, "knotserver/%s", version)
734
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
735
+
defer cancel()
736
+
737
+
sizes, err := gr.AnalyzeLanguages(ctx)
738
+
if err != nil {
739
+
l.Error("failed to analyze languages", "error", err.Error())
740
+
writeError(w, err.Error(), http.StatusNoContent)
741
+
return
742
+
}
743
+
744
+
resp := types.RepoLanguageResponse{Languages: sizes}
745
+
746
+
writeJSON(w, resp)
747
+
}
748
+
749
+
// func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
750
+
// l := h.l.With("handler", "RepoForkSync")
751
+
//
752
+
// data := struct {
753
+
// Did string `json:"did"`
754
+
// Source string `json:"source"`
755
+
// Name string `json:"name,omitempty"`
756
+
// }{}
757
+
//
758
+
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
759
+
// writeError(w, "invalid request body", http.StatusBadRequest)
760
+
// return
761
+
// }
762
+
//
763
+
// did := data.Did
764
+
// source := data.Source
765
+
//
766
+
// if did == "" || source == "" {
767
+
// l.Error("invalid request body, empty did or name")
768
+
// w.WriteHeader(http.StatusBadRequest)
769
+
// return
770
+
// }
771
+
//
772
+
// var name string
773
+
// if data.Name != "" {
774
+
// name = data.Name
775
+
// } else {
776
+
// name = filepath.Base(source)
777
+
// }
778
+
//
779
+
// branch := chi.URLParam(r, "branch")
780
+
// branch, _ = url.PathUnescape(branch)
781
+
//
782
+
// relativeRepoPath := filepath.Join(did, name)
783
+
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
784
+
//
785
+
// gr, err := git.Open(repoPath, branch)
786
+
// if err != nil {
787
+
// log.Println(err)
788
+
// notFound(w)
789
+
// return
790
+
// }
791
+
//
792
+
// err = gr.Sync()
793
+
// if err != nil {
794
+
// l.Error("error syncing repo fork", "error", err.Error())
795
+
// writeError(w, err.Error(), http.StatusInternalServerError)
796
+
// return
797
+
// }
798
+
//
799
+
// w.WriteHeader(http.StatusNoContent)
800
+
// }
801
+
802
+
// func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
803
+
// l := h.l.With("handler", "RepoFork")
804
+
//
805
+
// data := struct {
806
+
// Did string `json:"did"`
807
+
// Source string `json:"source"`
808
+
// Name string `json:"name,omitempty"`
809
+
// }{}
810
+
//
811
+
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
812
+
// writeError(w, "invalid request body", http.StatusBadRequest)
813
+
// return
814
+
// }
815
+
//
816
+
// did := data.Did
817
+
// source := data.Source
818
+
//
819
+
// if did == "" || source == "" {
820
+
// l.Error("invalid request body, empty did or name")
821
+
// w.WriteHeader(http.StatusBadRequest)
822
+
// return
823
+
// }
824
+
//
825
+
// var name string
826
+
// if data.Name != "" {
827
+
// name = data.Name
828
+
// } else {
829
+
// name = filepath.Base(source)
830
+
// }
831
+
//
832
+
// relativeRepoPath := filepath.Join(did, name)
833
+
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
834
+
//
835
+
// err := git.Fork(repoPath, source)
836
+
// if err != nil {
837
+
// l.Error("forking repo", "error", err.Error())
838
+
// writeError(w, err.Error(), http.StatusInternalServerError)
839
+
// return
840
+
// }
841
+
//
842
+
// // add perms for this user to access the repo
843
+
// err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
844
+
// if err != nil {
845
+
// l.Error("adding repo permissions", "error", err.Error())
846
+
// writeError(w, err.Error(), http.StatusInternalServerError)
847
+
// return
848
+
// }
849
+
//
850
+
// hook.SetupRepo(
851
+
// hook.Config(
852
+
// hook.WithScanPath(h.c.Repo.ScanPath),
853
+
// hook.WithInternalApi(h.c.Server.InternalListenAddr),
854
+
// ),
855
+
// repoPath,
856
+
// )
857
+
//
858
+
// w.WriteHeader(http.StatusNoContent)
859
+
// }
860
+
861
+
// func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
862
+
// l := h.l.With("handler", "RemoveRepo")
863
+
//
864
+
// data := struct {
865
+
// Did string `json:"did"`
866
+
// Name string `json:"name"`
867
+
// }{}
868
+
//
869
+
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
870
+
// writeError(w, "invalid request body", http.StatusBadRequest)
871
+
// return
872
+
// }
873
+
//
874
+
// did := data.Did
875
+
// name := data.Name
876
+
//
877
+
// if did == "" || name == "" {
878
+
// l.Error("invalid request body, empty did or name")
879
+
// w.WriteHeader(http.StatusBadRequest)
880
+
// return
881
+
// }
882
+
//
883
+
// relativeRepoPath := filepath.Join(did, name)
884
+
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
885
+
// err := os.RemoveAll(repoPath)
886
+
// if err != nil {
887
+
// l.Error("removing repo", "error", err.Error())
888
+
// writeError(w, err.Error(), http.StatusInternalServerError)
889
+
// return
890
+
// }
891
+
//
892
+
// w.WriteHeader(http.StatusNoContent)
893
+
//
894
+
// }
895
+
896
+
// func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
897
+
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
898
+
//
899
+
// data := types.MergeRequest{}
900
+
//
901
+
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
902
+
// writeError(w, err.Error(), http.StatusBadRequest)
903
+
// h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
904
+
// return
905
+
// }
906
+
//
907
+
// mo := &git.MergeOptions{
908
+
// AuthorName: data.AuthorName,
909
+
// AuthorEmail: data.AuthorEmail,
910
+
// CommitBody: data.CommitBody,
911
+
// CommitMessage: data.CommitMessage,
912
+
// }
913
+
//
914
+
// patch := data.Patch
915
+
// branch := data.Branch
916
+
// gr, err := git.Open(path, branch)
917
+
// if err != nil {
918
+
// notFound(w)
919
+
// return
920
+
// }
921
+
//
922
+
// mo.FormatPatch = patchutil.IsFormatPatch(patch)
923
+
//
924
+
// if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
925
+
// var mergeErr *git.ErrMerge
926
+
// if errors.As(err, &mergeErr) {
927
+
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
928
+
// for i, conflict := range mergeErr.Conflicts {
929
+
// conflicts[i] = types.ConflictInfo{
930
+
// Filename: conflict.Filename,
931
+
// Reason: conflict.Reason,
932
+
// }
933
+
// }
934
+
// response := types.MergeCheckResponse{
935
+
// IsConflicted: true,
936
+
// Conflicts: conflicts,
937
+
// Message: mergeErr.Message,
938
+
// }
939
+
// writeConflict(w, response)
940
+
// h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
941
+
// } else {
942
+
// writeError(w, err.Error(), http.StatusBadRequest)
943
+
// h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
944
+
// }
945
+
// return
946
+
// }
947
+
//
948
+
// w.WriteHeader(http.StatusOK)
949
+
// }
950
+
951
+
// func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
952
+
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
953
+
//
954
+
// var data struct {
955
+
// Patch string `json:"patch"`
956
+
// Branch string `json:"branch"`
957
+
// }
958
+
//
959
+
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
960
+
// writeError(w, err.Error(), http.StatusBadRequest)
961
+
// h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
962
+
// return
963
+
// }
964
+
//
965
+
// patch := data.Patch
966
+
// branch := data.Branch
967
+
// gr, err := git.Open(path, branch)
968
+
// if err != nil {
969
+
// notFound(w)
970
+
// return
971
+
// }
972
+
//
973
+
// err = gr.MergeCheck([]byte(patch), branch)
974
+
// if err == nil {
975
+
// response := types.MergeCheckResponse{
976
+
// IsConflicted: false,
977
+
// }
978
+
// writeJSON(w, response)
979
+
// return
980
+
// }
981
+
//
982
+
// var mergeErr *git.ErrMerge
983
+
// if errors.As(err, &mergeErr) {
984
+
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
985
+
// for i, conflict := range mergeErr.Conflicts {
986
+
// conflicts[i] = types.ConflictInfo{
987
+
// Filename: conflict.Filename,
988
+
// Reason: conflict.Reason,
989
+
// }
990
+
// }
991
+
// response := types.MergeCheckResponse{
992
+
// IsConflicted: true,
993
+
// Conflicts: conflicts,
994
+
// Message: mergeErr.Message,
995
+
// }
996
+
// writeConflict(w, response)
997
+
// h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
998
+
// return
999
+
// }
1000
+
// writeError(w, err.Error(), http.StatusInternalServerError)
1001
+
// h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1002
+
// }
1003
+
1004
+
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1005
+
rev1 := chi.URLParam(r, "rev1")
1006
+
rev1, _ = url.PathUnescape(rev1)
1007
+
1008
+
rev2 := chi.URLParam(r, "rev2")
1009
+
rev2, _ = url.PathUnescape(rev2)
1010
+
1011
+
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1012
+
1013
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1014
+
gr, err := git.PlainOpen(path)
1015
+
if err != nil {
1016
+
notFound(w)
1017
+
return
1018
+
}
1019
+
1020
+
commit1, err := gr.ResolveRevision(rev1)
1021
+
if err != nil {
1022
+
l.Error("error resolving revision 1", "msg", err.Error())
1023
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1024
+
return
1025
+
}
1026
+
1027
+
commit2, err := gr.ResolveRevision(rev2)
1028
+
if err != nil {
1029
+
l.Error("error resolving revision 2", "msg", err.Error())
1030
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1031
+
return
1032
+
}
1033
+
1034
+
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1035
+
if err != nil {
1036
+
l.Error("error comparing revisions", "msg", err.Error())
1037
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
1038
+
return
1039
+
}
1040
+
1041
+
writeJSON(w, types.RepoFormatPatchResponse{
1042
+
Rev1: commit1.Hash.String(),
1043
+
Rev2: commit2.Hash.String(),
1044
+
FormatPatch: formatPatch,
1045
+
Patch: rawPatch,
1046
+
})
1047
+
}
1048
+
1049
+
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1050
+
l := h.l.With("handler", "DefaultBranch")
1051
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1052
+
1053
+
gr, err := git.Open(path, "")
1054
+
if err != nil {
1055
+
notFound(w)
1056
+
return
1057
+
}
1058
+
1059
+
branch, err := gr.FindMainBranch()
1060
+
if err != nil {
1061
+
writeError(w, err.Error(), http.StatusInternalServerError)
1062
+
l.Error("getting default branch", "error", err.Error())
1063
+
return
1064
+
}
1065
+
1066
+
writeJSON(w, types.RepoDefaultBranchResponse{
1067
+
Branch: branch,
1068
+
})
211
1069
}
-10
knotserver/http_util.go
-10
knotserver/http_util.go
···
20
20
func notFound(w http.ResponseWriter) {
21
21
writeError(w, "not found", http.StatusNotFound)
22
22
}
23
-
24
-
func writeMsg(w http.ResponseWriter, msg string) {
25
-
writeJSON(w, map[string]string{"msg": msg})
26
-
}
27
-
28
-
func writeConflict(w http.ResponseWriter, data interface{}) {
29
-
w.Header().Set("Content-Type", "application/json")
30
-
w.WriteHeader(http.StatusConflict)
31
-
json.NewEncoder(w).Encode(data)
32
-
}
+75
-90
knotserver/ingester.go
+75
-90
knotserver/ingester.go
···
8
8
"net/http"
9
9
"net/url"
10
10
"path/filepath"
11
-
"slices"
12
11
"strings"
13
12
14
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
25
24
"tangled.sh/tangled.sh/core/workflow"
26
25
)
27
26
28
-
func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error {
27
+
func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error {
29
28
l := log.FromContext(ctx)
29
+
raw := json.RawMessage(event.Commit.Record)
30
+
did := event.Did
31
+
32
+
var record tangled.PublicKey
33
+
if err := json.Unmarshal(raw, &record); err != nil {
34
+
return fmt.Errorf("failed to unmarshal record: %w", err)
35
+
}
36
+
30
37
pk := db.PublicKey{
31
38
Did: did,
32
39
PublicKey: record,
···
39
46
return nil
40
47
}
41
48
42
-
func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error {
49
+
func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error {
43
50
l := log.FromContext(ctx)
51
+
raw := json.RawMessage(event.Commit.Record)
52
+
did := event.Did
53
+
54
+
var record tangled.KnotMember
55
+
if err := json.Unmarshal(raw, &record); err != nil {
56
+
return fmt.Errorf("failed to unmarshal record: %w", err)
57
+
}
44
58
45
59
if record.Domain != h.c.Server.Hostname {
46
60
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
···
59
73
}
60
74
l.Info("added member from firehose", "member", record.Subject)
61
75
62
-
if err := h.db.AddDid(did); err != nil {
76
+
if err := h.db.AddDid(record.Subject); err != nil {
63
77
l.Error("failed to add did", "error", err)
64
78
return fmt.Errorf("failed to add did: %w", err)
65
79
}
66
-
h.jc.AddDid(did)
80
+
h.jc.AddDid(record.Subject)
67
81
68
-
if err := h.fetchAndAddKeys(ctx, did); err != nil {
82
+
if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil {
69
83
return fmt.Errorf("failed to fetch and add keys: %w", err)
70
84
}
71
85
72
86
return nil
73
87
}
74
88
75
-
func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error {
89
+
func (h *Handle) processPull(ctx context.Context, event *models.Event) error {
90
+
raw := json.RawMessage(event.Commit.Record)
91
+
did := event.Did
92
+
93
+
var record tangled.RepoPull
94
+
if err := json.Unmarshal(raw, &record); err != nil {
95
+
return fmt.Errorf("failed to unmarshal record: %w", err)
96
+
}
97
+
76
98
l := log.FromContext(ctx)
77
99
l = l.With("handler", "processPull")
78
100
l = l.With("did", did)
79
-
l = l.With("target_repo", record.TargetRepo)
80
-
l = l.With("target_branch", record.TargetBranch)
101
+
l = l.With("target_repo", record.Target.Repo)
102
+
l = l.With("target_branch", record.Target.Branch)
81
103
82
104
if record.Source == nil {
83
-
reason := "not a branch-based pull request"
84
-
l.Info("ignoring pull record", "reason", reason)
85
-
return fmt.Errorf("ignoring pull record: %s", reason)
105
+
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
86
106
}
87
107
88
108
if record.Source.Repo != nil {
89
-
reason := "fork based pull"
90
-
l.Info("ignoring pull record", "reason", reason)
91
-
return fmt.Errorf("ignoring pull record: %s", reason)
109
+
return fmt.Errorf("ignoring pull record: fork based pull")
92
110
}
93
111
94
-
allDids, err := h.db.GetAllDids()
112
+
repoAt, err := syntax.ParseATURI(record.Target.Repo)
95
113
if err != nil {
96
-
return err
97
-
}
98
-
99
-
// presently: we only process PRs from collaborators for pipelines
100
-
if !slices.Contains(allDids, did) {
101
-
reason := "not a known did"
102
-
l.Info("rejecting pull record", "reason", reason)
103
-
return fmt.Errorf("rejected pull record: %s, %s", reason, did)
104
-
}
105
-
106
-
repoAt, err := syntax.ParseATURI(record.TargetRepo)
107
-
if err != nil {
108
-
return err
114
+
return fmt.Errorf("failed to parse ATURI: %w", err)
109
115
}
110
116
111
117
// resolve this aturi to extract the repo record
···
121
127
122
128
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
123
129
if err != nil {
124
-
return err
130
+
return fmt.Errorf("failed to resolver repo: %w", err)
125
131
}
126
132
127
133
repo := resp.Value.Val.(*tangled.Repo)
128
134
129
135
if repo.Knot != h.c.Server.Hostname {
130
-
reason := "not this knot"
131
-
l.Info("rejecting pull record", "reason", reason)
132
-
return fmt.Errorf("rejected pull record: %s", reason)
136
+
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
133
137
}
134
138
135
139
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
136
140
if err != nil {
137
-
return err
141
+
return fmt.Errorf("failed to construct relative repo path: %w", err)
138
142
}
139
143
140
144
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
141
145
if err != nil {
142
-
return err
146
+
return fmt.Errorf("failed to construct absolute repo path: %w", err)
143
147
}
144
148
145
149
gr, err := git.Open(repoPath, record.Source.Branch)
146
150
if err != nil {
147
-
return err
151
+
return fmt.Errorf("failed to open git repository: %w", err)
148
152
}
149
153
150
154
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
151
155
if err != nil {
152
-
return err
156
+
return fmt.Errorf("failed to open workflow directory: %w", err)
153
157
}
154
158
155
-
var pipeline workflow.Pipeline
159
+
var pipeline workflow.RawPipeline
156
160
for _, e := range workflowDir {
157
161
if !e.IsFile {
158
162
continue
···
164
168
continue
165
169
}
166
170
167
-
wf, err := workflow.FromFile(e.Name, contents)
168
-
if err != nil {
169
-
// TODO: log here, respond to client that is pushing
170
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
171
-
continue
172
-
}
173
-
174
-
pipeline = append(pipeline, wf)
171
+
pipeline = append(pipeline, workflow.RawWorkflow{
172
+
Name: e.Name,
173
+
Contents: contents,
174
+
})
175
175
}
176
176
177
177
trigger := tangled.Pipeline_PullRequestTriggerData{
178
178
Action: "create",
179
179
SourceBranch: record.Source.Branch,
180
180
SourceSha: record.Source.Sha,
181
-
TargetBranch: record.TargetBranch,
181
+
TargetBranch: record.Target.Branch,
182
182
}
183
183
184
184
compiler := workflow.Compiler{
···
193
193
},
194
194
}
195
195
196
-
cp := compiler.Compile(pipeline)
196
+
cp := compiler.Compile(compiler.Parse(pipeline))
197
197
eventJson, err := json.Marshal(cp)
198
198
if err != nil {
199
-
return err
199
+
return fmt.Errorf("failed to marshal pipeline event: %w", err)
200
200
}
201
201
202
202
// do not run empty pipelines
···
204
204
return nil
205
205
}
206
206
207
-
event := db.Event{
207
+
ev := db.Event{
208
208
Rkey: TID(),
209
209
Nsid: tangled.PipelineNSID,
210
210
EventJson: string(eventJson),
211
211
}
212
212
213
-
return h.db.InsertEvent(event, h.n)
213
+
return h.db.InsertEvent(ev, h.n)
214
214
}
215
215
216
216
// duplicated from add collaborator
217
-
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
217
+
func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error {
218
+
raw := json.RawMessage(event.Commit.Record)
219
+
did := event.Did
220
+
221
+
var record tangled.RepoCollaborator
222
+
if err := json.Unmarshal(raw, &record); err != nil {
223
+
return fmt.Errorf("failed to unmarshal record: %w", err)
224
+
}
225
+
218
226
repoAt, err := syntax.ParseATURI(record.Repo)
219
227
if err != nil {
220
228
return err
···
247
255
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
248
256
249
257
// check perms for this user
250
-
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
251
-
return fmt.Errorf("insufficient permissions: %w", err)
258
+
ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo)
259
+
if err != nil {
260
+
return fmt.Errorf("failed to check permissions: %w", err)
261
+
}
262
+
if !ok {
263
+
return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo)
252
264
}
253
265
254
266
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
···
290
302
return fmt.Errorf("error reading response body: %w", err)
291
303
}
292
304
293
-
for _, key := range strings.Split(string(plaintext), "\n") {
305
+
for key := range strings.SplitSeq(string(plaintext), "\n") {
294
306
if key == "" {
295
307
continue
296
308
}
···
307
319
}
308
320
309
321
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
310
-
did := event.Did
311
322
if event.Kind != models.EventKindCommit {
312
323
return nil
313
324
}
···
321
332
}
322
333
}()
323
334
324
-
raw := json.RawMessage(event.Commit.Record)
325
-
326
335
switch event.Commit.Collection {
327
336
case tangled.PublicKeyNSID:
328
-
var record tangled.PublicKey
329
-
if err := json.Unmarshal(raw, &record); err != nil {
330
-
return fmt.Errorf("failed to unmarshal record: %w", err)
331
-
}
332
-
if err := h.processPublicKey(ctx, did, record); err != nil {
333
-
return fmt.Errorf("failed to process public key: %w", err)
334
-
}
335
-
337
+
err = h.processPublicKey(ctx, event)
336
338
case tangled.KnotMemberNSID:
337
-
var record tangled.KnotMember
338
-
if err := json.Unmarshal(raw, &record); err != nil {
339
-
return fmt.Errorf("failed to unmarshal record: %w", err)
340
-
}
341
-
if err := h.processKnotMember(ctx, did, record); err != nil {
342
-
return fmt.Errorf("failed to process knot member: %w", err)
343
-
}
344
-
339
+
err = h.processKnotMember(ctx, event)
345
340
case tangled.RepoPullNSID:
346
-
var record tangled.RepoPull
347
-
if err := json.Unmarshal(raw, &record); err != nil {
348
-
return fmt.Errorf("failed to unmarshal record: %w", err)
349
-
}
350
-
if err := h.processPull(ctx, did, record); err != nil {
351
-
return fmt.Errorf("failed to process knot member: %w", err)
352
-
}
353
-
341
+
err = h.processPull(ctx, event)
354
342
case tangled.RepoCollaboratorNSID:
355
-
var record tangled.RepoCollaborator
356
-
if err := json.Unmarshal(raw, &record); err != nil {
357
-
return fmt.Errorf("failed to unmarshal record: %w", err)
358
-
}
359
-
if err := h.processCollaborator(ctx, did, record); err != nil {
360
-
return fmt.Errorf("failed to process knot member: %w", err)
361
-
}
343
+
err = h.processCollaborator(ctx, event)
344
+
}
362
345
346
+
if err != nil {
347
+
h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err)
363
348
}
364
349
365
-
return err
350
+
return nil
366
351
}
+20
-39
knotserver/internal.go
+20
-39
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
46
47
}
47
48
48
49
w.WriteHeader(http.StatusNoContent)
49
-
return
50
50
}
51
51
52
52
func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
···
62
62
data = append(data, j)
63
63
}
64
64
writeJSON(w, data)
65
-
return
66
65
}
67
66
68
67
type PushOptions struct {
···
145
144
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
146
145
}
147
146
148
-
meta := gr.RefUpdateMeta(line)
147
+
var errs error
148
+
meta, err := gr.RefUpdateMeta(line)
149
+
errors.Join(errs, err)
149
150
150
151
metaRecord := meta.AsRecord()
151
152
···
169
170
EventJson: string(eventJson),
170
171
}
171
172
172
-
return h.db.InsertEvent(event, h.n)
173
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
173
174
}
174
175
175
176
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
···
197
198
return err
198
199
}
199
200
200
-
pipelineParseErrors := []string{}
201
-
202
-
var pipeline workflow.Pipeline
201
+
var pipeline workflow.RawPipeline
203
202
for _, e := range workflowDir {
204
203
if !e.IsFile {
205
204
continue
···
211
210
continue
212
211
}
213
212
214
-
wf, err := workflow.FromFile(e.Name, contents)
215
-
if err != nil {
216
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
217
-
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
218
-
continue
219
-
}
220
-
221
-
pipeline = append(pipeline, wf)
213
+
pipeline = append(pipeline, workflow.RawWorkflow{
214
+
Name: e.Name,
215
+
Contents: contents,
216
+
})
222
217
}
223
218
224
219
trigger := tangled.Pipeline_PushTriggerData{
···
239
234
},
240
235
}
241
236
242
-
cp := compiler.Compile(pipeline)
237
+
cp := compiler.Compile(compiler.Parse(pipeline))
243
238
eventJson, err := json.Marshal(cp)
244
239
if err != nil {
245
240
return err
246
241
}
247
242
243
+
for _, e := range compiler.Diagnostics.Errors {
244
+
*clientMsgs = append(*clientMsgs, e.String())
245
+
}
246
+
248
247
if pushOptions.verboseCi {
249
-
hasDiagnostics := false
250
-
if len(pipelineParseErrors) > 0 {
251
-
hasDiagnostics = true
252
-
*clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
253
-
for _, error := range pipelineParseErrors {
254
-
*clientMsgs = append(*clientMsgs, error)
255
-
}
248
+
if compiler.Diagnostics.IsEmpty() {
249
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
256
250
}
257
-
if len(compiler.Diagnostics.Errors) > 0 {
258
-
hasDiagnostics = true
259
-
*clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
260
-
for _, error := range compiler.Diagnostics.Errors {
261
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
262
-
}
263
-
}
264
-
if len(compiler.Diagnostics.Warnings) > 0 {
265
-
hasDiagnostics = true
266
-
*clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
267
-
for _, warning := range compiler.Diagnostics.Warnings {
268
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
269
-
}
270
-
}
271
-
if !hasDiagnostics {
272
-
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
251
+
252
+
for _, w := range compiler.Diagnostics.Warnings {
253
+
*clientMsgs = append(*clientMsgs, w.String())
273
254
}
274
255
}
275
256
-53
knotserver/middleware.go
-53
knotserver/middleware.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
7
-
"net/http"
8
-
"time"
9
-
)
10
-
11
-
func (h *Handle) VerifySignature(next http.Handler) http.Handler {
12
-
if h.c.Server.Dev {
13
-
return next
14
-
}
15
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16
-
signature := r.Header.Get("X-Signature")
17
-
if signature == "" || !h.verifyHMAC(signature, r) {
18
-
writeError(w, "signature verification failed", http.StatusForbidden)
19
-
return
20
-
}
21
-
next.ServeHTTP(w, r)
22
-
})
23
-
}
24
-
25
-
func (h *Handle) verifyHMAC(signature string, r *http.Request) bool {
26
-
secret := h.c.Server.Secret
27
-
timestamp := r.Header.Get("X-Timestamp")
28
-
if timestamp == "" {
29
-
return false
30
-
}
31
-
32
-
// Verify that the timestamp is not older than a minute
33
-
reqTime, err := time.Parse(time.RFC3339, timestamp)
34
-
if err != nil {
35
-
return false
36
-
}
37
-
if time.Since(reqTime) > time.Minute {
38
-
return false
39
-
}
40
-
41
-
message := r.Method + r.URL.Path + timestamp
42
-
43
-
mac := hmac.New(sha256.New, []byte(secret))
44
-
mac.Write([]byte(message))
45
-
expectedMAC := mac.Sum(nil)
46
-
47
-
signatureBytes, err := hex.DecodeString(signature)
48
-
if err != nil {
49
-
return false
50
-
}
51
-
52
-
return hmac.Equal(signatureBytes, expectedMAC)
53
-
}
+142
-1273
knotserver/routes.go
+142
-1273
knotserver/routes.go
···
1
1
package knotserver
2
2
3
3
import (
4
-
"compress/gzip"
5
4
"context"
6
-
"crypto/hmac"
7
-
"crypto/sha256"
8
-
"encoding/hex"
9
-
"encoding/json"
10
-
"errors"
11
5
"fmt"
12
-
"log"
6
+
"log/slog"
13
7
"net/http"
14
-
"net/url"
15
-
"os"
16
-
"path/filepath"
17
-
"strconv"
18
-
"strings"
19
-
"sync"
20
-
"time"
8
+
"runtime/debug"
21
9
22
-
securejoin "github.com/cyphar/filepath-securejoin"
23
-
"github.com/gliderlabs/ssh"
24
10
"github.com/go-chi/chi/v5"
25
-
gogit "github.com/go-git/go-git/v5"
26
-
"github.com/go-git/go-git/v5/plumbing"
27
-
"github.com/go-git/go-git/v5/plumbing/object"
28
-
"tangled.sh/tangled.sh/core/hook"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/jetstream"
13
+
"tangled.sh/tangled.sh/core/knotserver/config"
29
14
"tangled.sh/tangled.sh/core/knotserver/db"
30
-
"tangled.sh/tangled.sh/core/knotserver/git"
31
-
"tangled.sh/tangled.sh/core/patchutil"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
17
+
"tangled.sh/tangled.sh/core/notifier"
32
18
"tangled.sh/tangled.sh/core/rbac"
33
-
"tangled.sh/tangled.sh/core/types"
19
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
34
20
)
35
21
36
-
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
37
-
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
38
-
}
39
-
40
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
41
-
w.Header().Set("Content-Type", "application/json")
42
-
43
-
capabilities := map[string]any{
44
-
"pull_requests": map[string]any{
45
-
"format_patch": true,
46
-
"patch_submissions": true,
47
-
"branch_submissions": true,
48
-
"fork_submissions": true,
49
-
},
50
-
}
51
-
52
-
jsonData, err := json.Marshal(capabilities)
53
-
if err != nil {
54
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
55
-
return
56
-
}
57
-
58
-
w.Write(jsonData)
59
-
}
60
-
61
-
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
62
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
63
-
l := h.l.With("path", path, "handler", "RepoIndex")
64
-
ref := chi.URLParam(r, "ref")
65
-
ref, _ = url.PathUnescape(ref)
66
-
67
-
gr, err := git.Open(path, ref)
68
-
if err != nil {
69
-
plain, err2 := git.PlainOpen(path)
70
-
if err2 != nil {
71
-
l.Error("opening repo", "error", err2.Error())
72
-
notFound(w)
73
-
return
74
-
}
75
-
branches, _ := plain.Branches()
76
-
77
-
log.Println(err)
78
-
79
-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
80
-
resp := types.RepoIndexResponse{
81
-
IsEmpty: true,
82
-
Branches: branches,
83
-
}
84
-
writeJSON(w, resp)
85
-
return
86
-
} else {
87
-
l.Error("opening repo", "error", err.Error())
88
-
notFound(w)
89
-
return
90
-
}
91
-
}
92
-
93
-
var (
94
-
commits []*object.Commit
95
-
total int
96
-
branches []types.Branch
97
-
files []types.NiceTree
98
-
tags []object.Tag
99
-
)
100
-
101
-
var wg sync.WaitGroup
102
-
errorsCh := make(chan error, 5)
103
-
104
-
wg.Add(1)
105
-
go func() {
106
-
defer wg.Done()
107
-
cs, err := gr.Commits(0, 60)
108
-
if err != nil {
109
-
errorsCh <- fmt.Errorf("commits: %w", err)
110
-
return
111
-
}
112
-
commits = cs
113
-
}()
114
-
115
-
wg.Add(1)
116
-
go func() {
117
-
defer wg.Done()
118
-
t, err := gr.TotalCommits()
119
-
if err != nil {
120
-
errorsCh <- fmt.Errorf("calculating total: %w", err)
121
-
return
122
-
}
123
-
total = t
124
-
}()
125
-
126
-
wg.Add(1)
127
-
go func() {
128
-
defer wg.Done()
129
-
bs, err := gr.Branches()
130
-
if err != nil {
131
-
errorsCh <- fmt.Errorf("fetching branches: %w", err)
132
-
return
133
-
}
134
-
branches = bs
135
-
}()
136
-
137
-
wg.Add(1)
138
-
go func() {
139
-
defer wg.Done()
140
-
ts, err := gr.Tags()
141
-
if err != nil {
142
-
errorsCh <- fmt.Errorf("fetching tags: %w", err)
143
-
return
144
-
}
145
-
tags = ts
146
-
}()
147
-
148
-
wg.Add(1)
149
-
go func() {
150
-
defer wg.Done()
151
-
fs, err := gr.FileTree(r.Context(), "")
152
-
if err != nil {
153
-
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
154
-
return
155
-
}
156
-
files = fs
157
-
}()
158
-
159
-
wg.Wait()
160
-
close(errorsCh)
161
-
162
-
// show any errors
163
-
for err := range errorsCh {
164
-
l.Error("loading repo", "error", err.Error())
165
-
writeError(w, err.Error(), http.StatusInternalServerError)
166
-
return
167
-
}
168
-
169
-
rtags := []*types.TagReference{}
170
-
for _, tag := range tags {
171
-
var target *object.Tag
172
-
if tag.Target != plumbing.ZeroHash {
173
-
target = &tag
174
-
}
175
-
tr := types.TagReference{
176
-
Tag: target,
177
-
}
178
-
179
-
tr.Reference = types.Reference{
180
-
Name: tag.Name,
181
-
Hash: tag.Hash.String(),
182
-
}
183
-
184
-
if tag.Message != "" {
185
-
tr.Message = tag.Message
186
-
}
187
-
188
-
rtags = append(rtags, &tr)
189
-
}
190
-
191
-
var readmeContent string
192
-
var readmeFile string
193
-
for _, readme := range h.c.Repo.Readme {
194
-
content, _ := gr.FileContent(readme)
195
-
if len(content) > 0 {
196
-
readmeContent = string(content)
197
-
readmeFile = readme
198
-
}
199
-
}
200
-
201
-
if ref == "" {
202
-
mainBranch, err := gr.FindMainBranch()
203
-
if err != nil {
204
-
writeError(w, err.Error(), http.StatusInternalServerError)
205
-
l.Error("finding main branch", "error", err.Error())
206
-
return
207
-
}
208
-
ref = mainBranch
209
-
}
210
-
211
-
resp := types.RepoIndexResponse{
212
-
IsEmpty: false,
213
-
Ref: ref,
214
-
Commits: commits,
215
-
Description: getDescription(path),
216
-
Readme: readmeContent,
217
-
ReadmeFileName: readmeFile,
218
-
Files: files,
219
-
Branches: branches,
220
-
Tags: rtags,
221
-
TotalCommits: total,
222
-
}
223
-
224
-
writeJSON(w, resp)
225
-
return
226
-
}
227
-
228
-
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
229
-
treePath := chi.URLParam(r, "*")
230
-
ref := chi.URLParam(r, "ref")
231
-
ref, _ = url.PathUnescape(ref)
232
-
233
-
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
234
-
235
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
236
-
gr, err := git.Open(path, ref)
237
-
if err != nil {
238
-
notFound(w)
239
-
return
240
-
}
241
-
242
-
files, err := gr.FileTree(r.Context(), treePath)
243
-
if err != nil {
244
-
writeError(w, err.Error(), http.StatusInternalServerError)
245
-
l.Error("file tree", "error", err.Error())
246
-
return
247
-
}
248
-
249
-
resp := types.RepoTreeResponse{
250
-
Ref: ref,
251
-
Parent: treePath,
252
-
Description: getDescription(path),
253
-
DotDot: filepath.Dir(treePath),
254
-
Files: files,
255
-
}
256
-
257
-
writeJSON(w, resp)
258
-
return
259
-
}
260
-
261
-
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
262
-
treePath := chi.URLParam(r, "*")
263
-
ref := chi.URLParam(r, "ref")
264
-
ref, _ = url.PathUnescape(ref)
265
-
266
-
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
267
-
268
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
269
-
gr, err := git.Open(path, ref)
270
-
if err != nil {
271
-
notFound(w)
272
-
return
273
-
}
274
-
275
-
contents, err := gr.RawContent(treePath)
276
-
if err != nil {
277
-
writeError(w, err.Error(), http.StatusBadRequest)
278
-
l.Error("file content", "error", err.Error())
279
-
return
280
-
}
281
-
282
-
mimeType := http.DetectContentType(contents)
283
-
284
-
// exception for svg
285
-
if filepath.Ext(treePath) == ".svg" {
286
-
mimeType = "image/svg+xml"
287
-
}
288
-
289
-
// allow image, video, and text/plain files to be served directly
290
-
switch {
291
-
case strings.HasPrefix(mimeType, "image/"):
292
-
// allowed
293
-
case strings.HasPrefix(mimeType, "video/"):
294
-
// allowed
295
-
case strings.HasPrefix(mimeType, "text/plain"):
296
-
// allowed
297
-
default:
298
-
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
299
-
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
300
-
return
301
-
}
302
-
303
-
w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours
304
-
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
305
-
w.Header().Set("Content-Type", mimeType)
306
-
w.Write(contents)
307
-
}
308
-
309
-
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
310
-
treePath := chi.URLParam(r, "*")
311
-
ref := chi.URLParam(r, "ref")
312
-
ref, _ = url.PathUnescape(ref)
313
-
314
-
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
315
-
316
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
317
-
gr, err := git.Open(path, ref)
318
-
if err != nil {
319
-
notFound(w)
320
-
return
321
-
}
322
-
323
-
var isBinaryFile bool = false
324
-
contents, err := gr.FileContent(treePath)
325
-
if errors.Is(err, git.ErrBinaryFile) {
326
-
isBinaryFile = true
327
-
} else if errors.Is(err, object.ErrFileNotFound) {
328
-
notFound(w)
329
-
return
330
-
} else if err != nil {
331
-
writeError(w, err.Error(), http.StatusInternalServerError)
332
-
return
333
-
}
334
-
335
-
bytes := []byte(contents)
336
-
// safe := string(sanitize(bytes))
337
-
sizeHint := len(bytes)
338
-
339
-
resp := types.RepoBlobResponse{
340
-
Ref: ref,
341
-
Contents: string(bytes),
342
-
Path: treePath,
343
-
IsBinary: isBinaryFile,
344
-
SizeHint: uint64(sizeHint),
345
-
}
346
-
347
-
h.showFile(resp, w, l)
348
-
}
349
-
350
-
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
351
-
name := chi.URLParam(r, "name")
352
-
file := chi.URLParam(r, "file")
353
-
354
-
l := h.l.With("handler", "Archive", "name", name, "file", file)
355
-
356
-
// TODO: extend this to add more files compression (e.g.: xz)
357
-
if !strings.HasSuffix(file, ".tar.gz") {
358
-
notFound(w)
359
-
return
360
-
}
361
-
362
-
ref := strings.TrimSuffix(file, ".tar.gz")
363
-
364
-
// This allows the browser to use a proper name for the file when
365
-
// downloading
366
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
367
-
setContentDisposition(w, filename)
368
-
setGZipMIME(w)
369
-
370
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
371
-
gr, err := git.Open(path, ref)
372
-
if err != nil {
373
-
notFound(w)
374
-
return
375
-
}
376
-
377
-
gw := gzip.NewWriter(w)
378
-
defer gw.Close()
379
-
380
-
prefix := fmt.Sprintf("%s-%s", name, ref)
381
-
err = gr.WriteTar(gw, prefix)
382
-
if err != nil {
383
-
// once we start writing to the body we can't report error anymore
384
-
// so we are only left with printing the error.
385
-
l.Error("writing tar file", "error", err.Error())
386
-
return
387
-
}
388
-
389
-
err = gw.Flush()
390
-
if err != nil {
391
-
// once we start writing to the body we can't report error anymore
392
-
// so we are only left with printing the error.
393
-
l.Error("flushing?", "error", err.Error())
394
-
return
395
-
}
22
+
type Handle struct {
23
+
c *config.Config
24
+
db *db.DB
25
+
jc *jetstream.JetstreamClient
26
+
e *rbac.Enforcer
27
+
l *slog.Logger
28
+
n *notifier.Notifier
29
+
resolver *idresolver.Resolver
396
30
}
397
31
398
-
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
399
-
ref := chi.URLParam(r, "ref")
400
-
ref, _ = url.PathUnescape(ref)
401
-
402
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
403
-
404
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
405
-
406
-
gr, err := git.Open(path, ref)
407
-
if err != nil {
408
-
notFound(w)
409
-
return
410
-
}
411
-
412
-
// Get page parameters
413
-
page := 1
414
-
pageSize := 30
415
-
416
-
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
417
-
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
418
-
page = p
419
-
}
420
-
}
32
+
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
33
+
r := chi.NewRouter()
421
34
422
-
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
423
-
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
424
-
pageSize = ps
425
-
}
35
+
h := Handle{
36
+
c: c,
37
+
db: db,
38
+
e: e,
39
+
l: l,
40
+
jc: jc,
41
+
n: n,
42
+
resolver: idresolver.DefaultResolver(),
426
43
}
427
44
428
-
// convert to offset/limit
429
-
offset := (page - 1) * pageSize
430
-
limit := pageSize
431
-
432
-
commits, err := gr.Commits(offset, limit)
45
+
err := e.AddKnot(rbac.ThisServer)
433
46
if err != nil {
434
-
writeError(w, err.Error(), http.StatusInternalServerError)
435
-
l.Error("fetching commits", "error", err.Error())
436
-
return
437
-
}
438
-
439
-
total := len(commits)
440
-
441
-
resp := types.RepoLogResponse{
442
-
Commits: commits,
443
-
Ref: ref,
444
-
Description: getDescription(path),
445
-
Log: true,
446
-
Total: total,
447
-
Page: page,
448
-
PerPage: pageSize,
47
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
449
48
}
450
49
451
-
writeJSON(w, resp)
452
-
return
453
-
}
454
-
455
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
456
-
ref := chi.URLParam(r, "ref")
457
-
ref, _ = url.PathUnescape(ref)
458
-
459
-
l := h.l.With("handler", "Diff", "ref", ref)
460
-
461
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
462
-
gr, err := git.Open(path, ref)
463
-
if err != nil {
464
-
notFound(w)
465
-
return
50
+
// configure owner
51
+
if err = h.configureOwner(); err != nil {
52
+
return nil, err
466
53
}
54
+
h.l.Info("owner set", "did", h.c.Server.Owner)
55
+
h.jc.AddDid(h.c.Server.Owner)
467
56
468
-
diff, err := gr.Diff()
57
+
// configure known-dids in jetstream consumer
58
+
dids, err := h.db.GetAllDids()
469
59
if err != nil {
470
-
writeError(w, err.Error(), http.StatusInternalServerError)
471
-
l.Error("getting diff", "error", err.Error())
472
-
return
473
-
}
474
-
475
-
resp := types.RepoCommitResponse{
476
-
Ref: ref,
477
-
Diff: diff,
60
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
478
61
}
479
-
480
-
writeJSON(w, resp)
481
-
return
482
-
}
483
-
484
-
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
485
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
486
-
l := h.l.With("handler", "Refs")
487
-
488
-
gr, err := git.Open(path, "")
489
-
if err != nil {
490
-
notFound(w)
491
-
return
62
+
for _, d := range dids {
63
+
jc.AddDid(d)
492
64
}
493
65
494
-
tags, err := gr.Tags()
66
+
err = h.jc.StartJetstream(ctx, h.processMessages)
495
67
if err != nil {
496
-
// Non-fatal, we *should* have at least one branch to show.
497
-
l.Warn("getting tags", "error", err.Error())
68
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
498
69
}
499
70
500
-
rtags := []*types.TagReference{}
501
-
for _, tag := range tags {
502
-
var target *object.Tag
503
-
if tag.Target != plumbing.ZeroHash {
504
-
target = &tag
505
-
}
506
-
tr := types.TagReference{
507
-
Tag: target,
508
-
}
71
+
r.Get("/", h.Index)
72
+
r.Get("/capabilities", h.Capabilities)
73
+
r.Get("/version", h.Version)
74
+
r.Get("/owner", func(w http.ResponseWriter, r *http.Request) {
75
+
w.Write([]byte(h.c.Server.Owner))
76
+
})
77
+
r.Route("/{did}", func(r chi.Router) {
78
+
// Repo routes
79
+
r.Route("/{name}", func(r chi.Router) {
509
80
510
-
tr.Reference = types.Reference{
511
-
Name: tag.Name,
512
-
Hash: tag.Hash.String(),
513
-
}
81
+
r.Route("/languages", func(r chi.Router) {
82
+
r.Get("/", h.RepoLanguages)
83
+
r.Get("/{ref}", h.RepoLanguages)
84
+
})
514
85
515
-
if tag.Message != "" {
516
-
tr.Message = tag.Message
517
-
}
86
+
r.Get("/", h.RepoIndex)
87
+
r.Get("/info/refs", h.InfoRefs)
88
+
r.Post("/git-upload-pack", h.UploadPack)
89
+
r.Post("/git-receive-pack", h.ReceivePack)
90
+
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
518
91
519
-
rtags = append(rtags, &tr)
520
-
}
92
+
r.Route("/tree/{ref}", func(r chi.Router) {
93
+
r.Get("/", h.RepoIndex)
94
+
r.Get("/*", h.RepoTree)
95
+
})
521
96
522
-
resp := types.RepoTagsResponse{
523
-
Tags: rtags,
524
-
}
97
+
r.Route("/blob/{ref}", func(r chi.Router) {
98
+
r.Get("/*", h.Blob)
99
+
})
525
100
526
-
writeJSON(w, resp)
527
-
return
528
-
}
101
+
r.Route("/raw/{ref}", func(r chi.Router) {
102
+
r.Get("/*", h.BlobRaw)
103
+
})
529
104
530
-
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
531
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
105
+
r.Get("/log/{ref}", h.Log)
106
+
r.Get("/archive/{file}", h.Archive)
107
+
r.Get("/commit/{ref}", h.Diff)
108
+
r.Get("/tags", h.Tags)
109
+
r.Route("/branches", func(r chi.Router) {
110
+
r.Get("/", h.Branches)
111
+
r.Get("/{branch}", h.Branch)
112
+
r.Get("/default", h.DefaultBranch)
113
+
})
114
+
})
115
+
})
532
116
533
-
gr, err := git.PlainOpen(path)
534
-
if err != nil {
535
-
notFound(w)
536
-
return
537
-
}
117
+
// xrpc apis
118
+
r.Mount("/xrpc", h.XrpcRouter())
538
119
539
-
branches, _ := gr.Branches()
120
+
// Socket that streams git oplogs
121
+
r.Get("/events", h.Events)
540
122
541
-
resp := types.RepoBranchesResponse{
542
-
Branches: branches,
543
-
}
123
+
// All public keys on the knot.
124
+
r.Get("/keys", h.Keys)
544
125
545
-
writeJSON(w, resp)
546
-
return
126
+
return r, nil
547
127
}
548
128
549
-
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
550
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
551
-
branchName := chi.URLParam(r, "branch")
552
-
branchName, _ = url.PathUnescape(branchName)
129
+
func (h *Handle) XrpcRouter() http.Handler {
130
+
logger := tlog.New("knots")
553
131
554
-
l := h.l.With("handler", "Branch")
132
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
555
133
556
-
gr, err := git.PlainOpen(path)
557
-
if err != nil {
558
-
notFound(w)
559
-
return
134
+
xrpc := &xrpc.Xrpc{
135
+
Config: h.c,
136
+
Db: h.db,
137
+
Ingester: h.jc,
138
+
Enforcer: h.e,
139
+
Logger: logger,
140
+
Notifier: h.n,
141
+
Resolver: h.resolver,
142
+
ServiceAuth: serviceAuth,
560
143
}
561
-
562
-
ref, err := gr.Branch(branchName)
563
-
if err != nil {
564
-
l.Error("getting branch", "error", err.Error())
565
-
writeError(w, err.Error(), http.StatusInternalServerError)
566
-
return
567
-
}
568
-
569
-
commit, err := gr.Commit(ref.Hash())
570
-
if err != nil {
571
-
l.Error("getting commit object", "error", err.Error())
572
-
writeError(w, err.Error(), http.StatusInternalServerError)
573
-
return
574
-
}
575
-
576
-
defaultBranch, err := gr.FindMainBranch()
577
-
isDefault := false
578
-
if err != nil {
579
-
l.Error("getting default branch", "error", err.Error())
580
-
// do not quit though
581
-
} else if defaultBranch == branchName {
582
-
isDefault = true
583
-
}
584
-
585
-
resp := types.RepoBranchResponse{
586
-
Branch: types.Branch{
587
-
Reference: types.Reference{
588
-
Name: ref.Name().Short(),
589
-
Hash: ref.Hash().String(),
590
-
},
591
-
Commit: commit,
592
-
IsDefault: isDefault,
593
-
},
594
-
}
595
-
596
-
writeJSON(w, resp)
597
-
return
144
+
return xrpc.Router()
598
145
}
599
146
600
-
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
601
-
l := h.l.With("handler", "Keys")
602
-
603
-
switch r.Method {
604
-
case http.MethodGet:
605
-
keys, err := h.db.GetAllPublicKeys()
606
-
if err != nil {
607
-
writeError(w, err.Error(), http.StatusInternalServerError)
608
-
l.Error("getting public keys", "error", err.Error())
609
-
return
610
-
}
147
+
// version is set during build time.
148
+
var version string
611
149
612
-
data := make([]map[string]any, 0)
613
-
for _, key := range keys {
614
-
j := key.JSON()
615
-
data = append(data, j)
616
-
}
617
-
writeJSON(w, data)
618
-
return
619
-
620
-
case http.MethodPut:
621
-
pk := db.PublicKey{}
622
-
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
623
-
writeError(w, "invalid request body", http.StatusBadRequest)
150
+
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
151
+
if version == "" {
152
+
info, ok := debug.ReadBuildInfo()
153
+
if !ok {
154
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
624
155
return
625
156
}
626
157
627
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
628
-
if err != nil {
629
-
writeError(w, "invalid pubkey", http.StatusBadRequest)
158
+
var modVer string
159
+
for _, mod := range info.Deps {
160
+
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
161
+
version = mod.Version
162
+
break
163
+
}
630
164
}
631
165
632
-
if err := h.db.AddPublicKey(pk); err != nil {
633
-
writeError(w, err.Error(), http.StatusInternalServerError)
634
-
l.Error("adding public key", "error", err.Error())
635
-
return
166
+
if modVer == "" {
167
+
version = "unknown"
636
168
}
637
-
638
-
w.WriteHeader(http.StatusNoContent)
639
-
return
640
169
}
641
-
}
642
170
643
-
func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) {
644
-
l := h.l.With("handler", "NewRepo")
645
-
646
-
data := struct {
647
-
Did string `json:"did"`
648
-
Name string `json:"name"`
649
-
DefaultBranch string `json:"default_branch,omitempty"`
650
-
}{}
651
-
652
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
653
-
writeError(w, "invalid request body", http.StatusBadRequest)
654
-
return
655
-
}
656
-
657
-
if data.DefaultBranch == "" {
658
-
data.DefaultBranch = h.c.Repo.MainBranch
659
-
}
660
-
661
-
did := data.Did
662
-
name := data.Name
663
-
defaultBranch := data.DefaultBranch
664
-
665
-
if err := validateRepoName(name); err != nil {
666
-
l.Error("creating repo", "error", err.Error())
667
-
writeError(w, err.Error(), http.StatusBadRequest)
668
-
return
669
-
}
670
-
671
-
relativeRepoPath := filepath.Join(did, name)
672
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
673
-
err := git.InitBare(repoPath, defaultBranch)
674
-
if err != nil {
675
-
l.Error("initializing bare repo", "error", err.Error())
676
-
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
677
-
writeError(w, "That repo already exists!", http.StatusConflict)
678
-
return
679
-
} else {
680
-
writeError(w, err.Error(), http.StatusInternalServerError)
681
-
return
682
-
}
683
-
}
684
-
685
-
// add perms for this user to access the repo
686
-
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
687
-
if err != nil {
688
-
l.Error("adding repo permissions", "error", err.Error())
689
-
writeError(w, err.Error(), http.StatusInternalServerError)
690
-
return
691
-
}
692
-
693
-
hook.SetupRepo(
694
-
hook.Config(
695
-
hook.WithScanPath(h.c.Repo.ScanPath),
696
-
hook.WithInternalApi(h.c.Server.InternalListenAddr),
697
-
),
698
-
repoPath,
699
-
)
700
-
701
-
w.WriteHeader(http.StatusNoContent)
171
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
172
+
fmt.Fprintf(w, "knotserver/%s", version)
702
173
}
703
174
704
-
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
705
-
l := h.l.With("handler", "RepoForkSync")
706
-
707
-
data := struct {
708
-
Did string `json:"did"`
709
-
Source string `json:"source"`
710
-
Name string `json:"name,omitempty"`
711
-
HiddenRef string `json:"hiddenref"`
712
-
}{}
713
-
714
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
715
-
writeError(w, "invalid request body", http.StatusBadRequest)
716
-
return
717
-
}
718
-
719
-
did := data.Did
720
-
source := data.Source
721
-
722
-
if did == "" || source == "" {
723
-
l.Error("invalid request body, empty did or name")
724
-
w.WriteHeader(http.StatusBadRequest)
725
-
return
726
-
}
175
+
func (h *Handle) configureOwner() error {
176
+
cfgOwner := h.c.Server.Owner
727
177
728
-
var name string
729
-
if data.Name != "" {
730
-
name = data.Name
731
-
} else {
732
-
name = filepath.Base(source)
733
-
}
178
+
rbacDomain := "thisserver"
734
179
735
-
branch := chi.URLParam(r, "branch")
736
-
branch, _ = url.PathUnescape(branch)
737
-
738
-
relativeRepoPath := filepath.Join(did, name)
739
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
740
-
741
-
gr, err := git.PlainOpen(repoPath)
180
+
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
742
181
if err != nil {
743
-
log.Println(err)
744
-
notFound(w)
745
-
return
746
-
}
747
-
748
-
forkCommit, err := gr.ResolveRevision(branch)
749
-
if err != nil {
750
-
l.Error("error resolving ref revision", "msg", err.Error())
751
-
writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
752
-
return
182
+
return err
753
183
}
754
184
755
-
sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
756
-
if err != nil {
757
-
l.Error("error resolving hidden ref revision", "msg", err.Error())
758
-
writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
759
-
return
760
-
}
185
+
switch len(existing) {
186
+
case 0:
187
+
// no owner configured, continue
188
+
case 1:
189
+
// find existing owner
190
+
existingOwner := existing[0]
761
191
762
-
status := types.UpToDate
763
-
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
764
-
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
765
-
if err != nil {
766
-
log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
767
-
return
192
+
// no ownership change, this is okay
193
+
if existingOwner == h.c.Server.Owner {
194
+
break
768
195
}
769
196
770
-
if isAncestor {
771
-
status = types.FastForwardable
772
-
} else {
773
-
status = types.Conflict
197
+
// remove existing owner
198
+
if err = h.db.RemoveDid(existingOwner); err != nil {
199
+
return err
774
200
}
775
-
}
776
-
777
-
w.Header().Set("Content-Type", "application/json")
778
-
json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
779
-
}
780
-
781
-
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
782
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
783
-
ref := chi.URLParam(r, "ref")
784
-
ref, _ = url.PathUnescape(ref)
785
-
786
-
l := h.l.With("handler", "RepoLanguages")
787
-
788
-
gr, err := git.Open(repoPath, ref)
789
-
if err != nil {
790
-
l.Error("opening repo", "error", err.Error())
791
-
notFound(w)
792
-
return
793
-
}
794
-
795
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
796
-
defer cancel()
797
-
798
-
sizes, err := gr.AnalyzeLanguages(ctx)
799
-
if err != nil {
800
-
l.Error("failed to analyze languages", "error", err.Error())
801
-
writeError(w, err.Error(), http.StatusNoContent)
802
-
return
803
-
}
804
-
805
-
resp := types.RepoLanguageResponse{Languages: sizes}
806
-
807
-
writeJSON(w, resp)
808
-
}
809
-
810
-
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
811
-
l := h.l.With("handler", "RepoForkSync")
812
-
813
-
data := struct {
814
-
Did string `json:"did"`
815
-
Source string `json:"source"`
816
-
Name string `json:"name,omitempty"`
817
-
}{}
818
-
819
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
820
-
writeError(w, "invalid request body", http.StatusBadRequest)
821
-
return
822
-
}
823
-
824
-
did := data.Did
825
-
source := data.Source
826
-
827
-
if did == "" || source == "" {
828
-
l.Error("invalid request body, empty did or name")
829
-
w.WriteHeader(http.StatusBadRequest)
830
-
return
831
-
}
832
-
833
-
var name string
834
-
if data.Name != "" {
835
-
name = data.Name
836
-
} else {
837
-
name = filepath.Base(source)
838
-
}
839
-
840
-
branch := chi.URLParam(r, "branch")
841
-
branch, _ = url.PathUnescape(branch)
842
-
843
-
relativeRepoPath := filepath.Join(did, name)
844
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
845
-
846
-
gr, err := git.PlainOpen(repoPath)
847
-
if err != nil {
848
-
log.Println(err)
849
-
notFound(w)
850
-
return
851
-
}
852
-
853
-
err = gr.Sync(branch)
854
-
if err != nil {
855
-
l.Error("error syncing repo fork", "error", err.Error())
856
-
writeError(w, err.Error(), http.StatusInternalServerError)
857
-
return
858
-
}
859
-
860
-
w.WriteHeader(http.StatusNoContent)
861
-
}
862
-
863
-
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
864
-
l := h.l.With("handler", "RepoFork")
865
-
866
-
data := struct {
867
-
Did string `json:"did"`
868
-
Source string `json:"source"`
869
-
Name string `json:"name,omitempty"`
870
-
}{}
871
-
872
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
873
-
writeError(w, "invalid request body", http.StatusBadRequest)
874
-
return
875
-
}
876
-
877
-
did := data.Did
878
-
source := data.Source
879
-
880
-
if did == "" || source == "" {
881
-
l.Error("invalid request body, empty did or name")
882
-
w.WriteHeader(http.StatusBadRequest)
883
-
return
884
-
}
885
-
886
-
var name string
887
-
if data.Name != "" {
888
-
name = data.Name
889
-
} else {
890
-
name = filepath.Base(source)
891
-
}
892
-
893
-
relativeRepoPath := filepath.Join(did, name)
894
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
895
-
896
-
err := git.Fork(repoPath, source)
897
-
if err != nil {
898
-
l.Error("forking repo", "error", err.Error())
899
-
writeError(w, err.Error(), http.StatusInternalServerError)
900
-
return
901
-
}
902
-
903
-
// add perms for this user to access the repo
904
-
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
905
-
if err != nil {
906
-
l.Error("adding repo permissions", "error", err.Error())
907
-
writeError(w, err.Error(), http.StatusInternalServerError)
908
-
return
909
-
}
910
-
911
-
hook.SetupRepo(
912
-
hook.Config(
913
-
hook.WithScanPath(h.c.Repo.ScanPath),
914
-
hook.WithInternalApi(h.c.Server.InternalListenAddr),
915
-
),
916
-
repoPath,
917
-
)
918
-
919
-
w.WriteHeader(http.StatusNoContent)
920
-
}
921
-
922
-
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
923
-
l := h.l.With("handler", "RemoveRepo")
924
-
925
-
data := struct {
926
-
Did string `json:"did"`
927
-
Name string `json:"name"`
928
-
}{}
929
-
930
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
931
-
writeError(w, "invalid request body", http.StatusBadRequest)
932
-
return
933
-
}
934
-
935
-
did := data.Did
936
-
name := data.Name
937
-
938
-
if did == "" || name == "" {
939
-
l.Error("invalid request body, empty did or name")
940
-
w.WriteHeader(http.StatusBadRequest)
941
-
return
942
-
}
943
-
944
-
relativeRepoPath := filepath.Join(did, name)
945
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
946
-
err := os.RemoveAll(repoPath)
947
-
if err != nil {
948
-
l.Error("removing repo", "error", err.Error())
949
-
writeError(w, err.Error(), http.StatusInternalServerError)
950
-
return
951
-
}
952
-
953
-
w.WriteHeader(http.StatusNoContent)
954
-
955
-
}
956
-
func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
957
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
958
-
959
-
data := types.MergeRequest{}
960
-
961
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
962
-
writeError(w, err.Error(), http.StatusBadRequest)
963
-
h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
964
-
return
965
-
}
966
-
967
-
mo := &git.MergeOptions{
968
-
AuthorName: data.AuthorName,
969
-
AuthorEmail: data.AuthorEmail,
970
-
CommitBody: data.CommitBody,
971
-
CommitMessage: data.CommitMessage,
972
-
}
973
-
974
-
patch := data.Patch
975
-
branch := data.Branch
976
-
gr, err := git.Open(path, branch)
977
-
if err != nil {
978
-
notFound(w)
979
-
return
980
-
}
981
-
982
-
mo.FormatPatch = patchutil.IsFormatPatch(patch)
983
-
984
-
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
985
-
var mergeErr *git.ErrMerge
986
-
if errors.As(err, &mergeErr) {
987
-
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
988
-
for i, conflict := range mergeErr.Conflicts {
989
-
conflicts[i] = types.ConflictInfo{
990
-
Filename: conflict.Filename,
991
-
Reason: conflict.Reason,
992
-
}
993
-
}
994
-
response := types.MergeCheckResponse{
995
-
IsConflicted: true,
996
-
Conflicts: conflicts,
997
-
Message: mergeErr.Message,
998
-
}
999
-
writeConflict(w, response)
1000
-
h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
1001
-
} else {
1002
-
writeError(w, err.Error(), http.StatusBadRequest)
1003
-
h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
201
+
if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
202
+
return err
1004
203
}
1005
-
return
1006
-
}
1007
204
1008
-
w.WriteHeader(http.StatusOK)
1009
-
}
1010
-
1011
-
func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
1012
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1013
-
1014
-
var data struct {
1015
-
Patch string `json:"patch"`
1016
-
Branch string `json:"branch"`
205
+
default:
206
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
1017
207
}
1018
208
1019
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1020
-
writeError(w, err.Error(), http.StatusBadRequest)
1021
-
h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
1022
-
return
209
+
if err = h.db.AddDid(cfgOwner); err != nil {
210
+
return fmt.Errorf("failed to add owner to DB: %w", err)
1023
211
}
1024
-
1025
-
patch := data.Patch
1026
-
branch := data.Branch
1027
-
gr, err := git.Open(path, branch)
1028
-
if err != nil {
1029
-
notFound(w)
1030
-
return
212
+
if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
213
+
return fmt.Errorf("failed to add owner to RBAC: %w", err)
1031
214
}
1032
215
1033
-
err = gr.MergeCheck([]byte(patch), branch)
1034
-
if err == nil {
1035
-
response := types.MergeCheckResponse{
1036
-
IsConflicted: false,
1037
-
}
1038
-
writeJSON(w, response)
1039
-
return
1040
-
}
1041
-
1042
-
var mergeErr *git.ErrMerge
1043
-
if errors.As(err, &mergeErr) {
1044
-
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
1045
-
for i, conflict := range mergeErr.Conflicts {
1046
-
conflicts[i] = types.ConflictInfo{
1047
-
Filename: conflict.Filename,
1048
-
Reason: conflict.Reason,
1049
-
}
1050
-
}
1051
-
response := types.MergeCheckResponse{
1052
-
IsConflicted: true,
1053
-
Conflicts: conflicts,
1054
-
Message: mergeErr.Message,
1055
-
}
1056
-
writeConflict(w, response)
1057
-
h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
1058
-
return
1059
-
}
1060
-
writeError(w, err.Error(), http.StatusInternalServerError)
1061
-
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1062
-
}
1063
-
1064
-
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1065
-
rev1 := chi.URLParam(r, "rev1")
1066
-
rev1, _ = url.PathUnescape(rev1)
1067
-
1068
-
rev2 := chi.URLParam(r, "rev2")
1069
-
rev2, _ = url.PathUnescape(rev2)
1070
-
1071
-
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1072
-
1073
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1074
-
gr, err := git.PlainOpen(path)
1075
-
if err != nil {
1076
-
notFound(w)
1077
-
return
1078
-
}
1079
-
1080
-
commit1, err := gr.ResolveRevision(rev1)
1081
-
if err != nil {
1082
-
l.Error("error resolving revision 1", "msg", err.Error())
1083
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1084
-
return
1085
-
}
1086
-
1087
-
commit2, err := gr.ResolveRevision(rev2)
1088
-
if err != nil {
1089
-
l.Error("error resolving revision 2", "msg", err.Error())
1090
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1091
-
return
1092
-
}
1093
-
1094
-
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1095
-
if err != nil {
1096
-
l.Error("error comparing revisions", "msg", err.Error())
1097
-
writeError(w, "error comparing revisions", http.StatusBadRequest)
1098
-
return
1099
-
}
1100
-
1101
-
writeJSON(w, types.RepoFormatPatchResponse{
1102
-
Rev1: commit1.Hash.String(),
1103
-
Rev2: commit2.Hash.String(),
1104
-
FormatPatch: formatPatch,
1105
-
Patch: rawPatch,
1106
-
})
1107
-
return
1108
-
}
1109
-
1110
-
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
1111
-
l := h.l.With("handler", "NewHiddenRef")
1112
-
1113
-
forkRef := chi.URLParam(r, "forkRef")
1114
-
forkRef, _ = url.PathUnescape(forkRef)
1115
-
1116
-
remoteRef := chi.URLParam(r, "remoteRef")
1117
-
remoteRef, _ = url.PathUnescape(remoteRef)
1118
-
1119
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1120
-
gr, err := git.PlainOpen(path)
1121
-
if err != nil {
1122
-
notFound(w)
1123
-
return
1124
-
}
1125
-
1126
-
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
1127
-
if err != nil {
1128
-
l.Error("error tracking hidden remote ref", "msg", err.Error())
1129
-
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
1130
-
return
1131
-
}
1132
-
1133
-
w.WriteHeader(http.StatusNoContent)
1134
-
return
1135
-
}
1136
-
1137
-
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
1138
-
l := h.l.With("handler", "AddMember")
1139
-
1140
-
data := struct {
1141
-
Did string `json:"did"`
1142
-
}{}
1143
-
1144
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1145
-
writeError(w, "invalid request body", http.StatusBadRequest)
1146
-
return
1147
-
}
1148
-
1149
-
did := data.Did
1150
-
1151
-
if err := h.db.AddDid(did); err != nil {
1152
-
l.Error("adding did", "error", err.Error())
1153
-
writeError(w, err.Error(), http.StatusInternalServerError)
1154
-
return
1155
-
}
1156
-
h.jc.AddDid(did)
1157
-
1158
-
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1159
-
l.Error("adding member", "error", err.Error())
1160
-
writeError(w, err.Error(), http.StatusInternalServerError)
1161
-
return
1162
-
}
1163
-
1164
-
if err := h.fetchAndAddKeys(r.Context(), did); err != nil {
1165
-
l.Error("fetching and adding keys", "error", err.Error())
1166
-
writeError(w, err.Error(), http.StatusInternalServerError)
1167
-
return
1168
-
}
1169
-
1170
-
w.WriteHeader(http.StatusNoContent)
1171
-
}
1172
-
1173
-
func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) {
1174
-
l := h.l.With("handler", "AddRepoCollaborator")
1175
-
1176
-
data := struct {
1177
-
Did string `json:"did"`
1178
-
}{}
1179
-
1180
-
ownerDid := chi.URLParam(r, "did")
1181
-
repo := chi.URLParam(r, "name")
1182
-
1183
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1184
-
writeError(w, "invalid request body", http.StatusBadRequest)
1185
-
return
1186
-
}
1187
-
1188
-
if err := h.db.AddDid(data.Did); err != nil {
1189
-
l.Error("adding did", "error", err.Error())
1190
-
writeError(w, err.Error(), http.StatusInternalServerError)
1191
-
return
1192
-
}
1193
-
h.jc.AddDid(data.Did)
1194
-
1195
-
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1196
-
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1197
-
l.Error("adding repo collaborator", "error", err.Error())
1198
-
writeError(w, err.Error(), http.StatusInternalServerError)
1199
-
return
1200
-
}
1201
-
1202
-
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
1203
-
l.Error("fetching and adding keys", "error", err.Error())
1204
-
writeError(w, err.Error(), http.StatusInternalServerError)
1205
-
return
1206
-
}
1207
-
1208
-
w.WriteHeader(http.StatusNoContent)
1209
-
}
1210
-
1211
-
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1212
-
l := h.l.With("handler", "DefaultBranch")
1213
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1214
-
1215
-
gr, err := git.Open(path, "")
1216
-
if err != nil {
1217
-
notFound(w)
1218
-
return
1219
-
}
1220
-
1221
-
branch, err := gr.FindMainBranch()
1222
-
if err != nil {
1223
-
writeError(w, err.Error(), http.StatusInternalServerError)
1224
-
l.Error("getting default branch", "error", err.Error())
1225
-
return
1226
-
}
1227
-
1228
-
writeJSON(w, types.RepoDefaultBranchResponse{
1229
-
Branch: branch,
1230
-
})
1231
-
}
1232
-
1233
-
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1234
-
l := h.l.With("handler", "SetDefaultBranch")
1235
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1236
-
1237
-
data := struct {
1238
-
Branch string `json:"branch"`
1239
-
}{}
1240
-
1241
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1242
-
writeError(w, err.Error(), http.StatusBadRequest)
1243
-
return
1244
-
}
1245
-
1246
-
gr, err := git.PlainOpen(path)
1247
-
if err != nil {
1248
-
notFound(w)
1249
-
return
1250
-
}
1251
-
1252
-
err = gr.SetDefaultBranch(data.Branch)
1253
-
if err != nil {
1254
-
writeError(w, err.Error(), http.StatusInternalServerError)
1255
-
l.Error("setting default branch", "error", err.Error())
1256
-
return
1257
-
}
1258
-
1259
-
w.WriteHeader(http.StatusNoContent)
1260
-
}
1261
-
1262
-
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
1263
-
l := h.l.With("handler", "Init")
1264
-
1265
-
if h.knotInitialized {
1266
-
writeError(w, "knot already initialized", http.StatusConflict)
1267
-
return
1268
-
}
1269
-
1270
-
data := struct {
1271
-
Did string `json:"did"`
1272
-
}{}
1273
-
1274
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1275
-
l.Error("failed to decode request body", "error", err.Error())
1276
-
writeError(w, "invalid request body", http.StatusBadRequest)
1277
-
return
1278
-
}
1279
-
1280
-
if data.Did == "" {
1281
-
l.Error("empty DID in request", "did", data.Did)
1282
-
writeError(w, "did is empty", http.StatusBadRequest)
1283
-
return
1284
-
}
1285
-
1286
-
if err := h.db.AddDid(data.Did); err != nil {
1287
-
l.Error("failed to add DID", "error", err.Error())
1288
-
writeError(w, err.Error(), http.StatusInternalServerError)
1289
-
return
1290
-
}
1291
-
h.jc.AddDid(data.Did)
1292
-
1293
-
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1294
-
l.Error("adding owner", "error", err.Error())
1295
-
writeError(w, err.Error(), http.StatusInternalServerError)
1296
-
return
1297
-
}
1298
-
1299
-
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
1300
-
l.Error("fetching and adding keys", "error", err.Error())
1301
-
writeError(w, err.Error(), http.StatusInternalServerError)
1302
-
return
1303
-
}
1304
-
1305
-
close(h.init)
1306
-
1307
-
mac := hmac.New(sha256.New, []byte(h.c.Server.Secret))
1308
-
mac.Write([]byte("ok"))
1309
-
w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil)))
1310
-
1311
-
w.WriteHeader(http.StatusNoContent)
1312
-
}
1313
-
1314
-
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
1315
-
w.Write([]byte("ok"))
1316
-
}
1317
-
1318
-
func validateRepoName(name string) error {
1319
-
// check for path traversal attempts
1320
-
if name == "." || name == ".." ||
1321
-
strings.Contains(name, "/") || strings.Contains(name, "\\") {
1322
-
return fmt.Errorf("Repository name contains invalid path characters")
1323
-
}
1324
-
1325
-
// check for sequences that could be used for traversal when normalized
1326
-
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
1327
-
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
1328
-
return fmt.Errorf("Repository name contains invalid path sequence")
1329
-
}
1330
-
1331
-
// then continue with character validation
1332
-
for _, char := range name {
1333
-
if !((char >= 'a' && char <= 'z') ||
1334
-
(char >= 'A' && char <= 'Z') ||
1335
-
(char >= '0' && char <= '9') ||
1336
-
char == '-' || char == '_' || char == '.') {
1337
-
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
1338
-
}
1339
-
}
1340
-
1341
-
// additional check to prevent multiple sequential dots
1342
-
if strings.Contains(name, "..") {
1343
-
return fmt.Errorf("Repository name cannot contain sequential dots")
1344
-
}
1345
-
1346
-
// if all checks pass
1347
216
return nil
1348
217
}
+156
knotserver/xrpc/create_repo.go
+156
knotserver/xrpc/create_repo.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"errors"
6
+
"fmt"
7
+
"net/http"
8
+
"path/filepath"
9
+
"strings"
10
+
11
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/bluesky-social/indigo/xrpc"
14
+
securejoin "github.com/cyphar/filepath-securejoin"
15
+
gogit "github.com/go-git/go-git/v5"
16
+
"tangled.sh/tangled.sh/core/api/tangled"
17
+
"tangled.sh/tangled.sh/core/hook"
18
+
"tangled.sh/tangled.sh/core/knotserver/git"
19
+
"tangled.sh/tangled.sh/core/rbac"
20
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
21
+
)
22
+
23
+
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
24
+
l := h.Logger.With("handler", "NewRepo")
25
+
fail := func(e xrpcerr.XrpcError) {
26
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
27
+
writeError(w, e, http.StatusBadRequest)
28
+
}
29
+
30
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
31
+
if !ok {
32
+
fail(xrpcerr.MissingActorDidError)
33
+
return
34
+
}
35
+
36
+
isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer)
37
+
if err != nil {
38
+
fail(xrpcerr.GenericError(err))
39
+
return
40
+
}
41
+
if !isMember {
42
+
fail(xrpcerr.AccessControlError(actorDid.String()))
43
+
return
44
+
}
45
+
46
+
var data tangled.RepoCreate_Input
47
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
48
+
fail(xrpcerr.GenericError(err))
49
+
return
50
+
}
51
+
52
+
rkey := data.Rkey
53
+
54
+
ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String())
55
+
if err != nil || ident.Handle.IsInvalidHandle() {
56
+
fail(xrpcerr.GenericError(err))
57
+
return
58
+
}
59
+
60
+
xrpcc := xrpc.Client{
61
+
Host: ident.PDSEndpoint(),
62
+
}
63
+
64
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
65
+
if err != nil {
66
+
fail(xrpcerr.GenericError(err))
67
+
return
68
+
}
69
+
70
+
repo := resp.Value.Val.(*tangled.Repo)
71
+
72
+
defaultBranch := h.Config.Repo.MainBranch
73
+
if data.DefaultBranch != nil && *data.DefaultBranch != "" {
74
+
defaultBranch = *data.DefaultBranch
75
+
}
76
+
77
+
if err := validateRepoName(repo.Name); err != nil {
78
+
l.Error("creating repo", "error", err.Error())
79
+
fail(xrpcerr.GenericError(err))
80
+
return
81
+
}
82
+
83
+
relativeRepoPath := filepath.Join(actorDid.String(), repo.Name)
84
+
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
85
+
86
+
if data.Source != nil && *data.Source != "" {
87
+
err = git.Fork(repoPath, *data.Source)
88
+
if err != nil {
89
+
l.Error("forking repo", "error", err.Error())
90
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
91
+
return
92
+
}
93
+
} else {
94
+
err = git.InitBare(repoPath, defaultBranch)
95
+
if err != nil {
96
+
l.Error("initializing bare repo", "error", err.Error())
97
+
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
98
+
fail(xrpcerr.RepoExistsError("repository already exists"))
99
+
return
100
+
} else {
101
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
102
+
return
103
+
}
104
+
}
105
+
}
106
+
107
+
// add perms for this user to access the repo
108
+
err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath)
109
+
if err != nil {
110
+
l.Error("adding repo permissions", "error", err.Error())
111
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
112
+
return
113
+
}
114
+
115
+
hook.SetupRepo(
116
+
hook.Config(
117
+
hook.WithScanPath(h.Config.Repo.ScanPath),
118
+
hook.WithInternalApi(h.Config.Server.InternalListenAddr),
119
+
),
120
+
repoPath,
121
+
)
122
+
123
+
w.WriteHeader(http.StatusOK)
124
+
}
125
+
126
+
func validateRepoName(name string) error {
127
+
// check for path traversal attempts
128
+
if name == "." || name == ".." ||
129
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
130
+
return fmt.Errorf("Repository name contains invalid path characters")
131
+
}
132
+
133
+
// check for sequences that could be used for traversal when normalized
134
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
135
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
136
+
return fmt.Errorf("Repository name contains invalid path sequence")
137
+
}
138
+
139
+
// then continue with character validation
140
+
for _, char := range name {
141
+
if !((char >= 'a' && char <= 'z') ||
142
+
(char >= 'A' && char <= 'Z') ||
143
+
(char >= '0' && char <= '9') ||
144
+
char == '-' || char == '_' || char == '.') {
145
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
146
+
}
147
+
}
148
+
149
+
// additional check to prevent multiple sequential dots
150
+
if strings.Contains(name, "..") {
151
+
return fmt.Errorf("Repository name cannot contain sequential dots")
152
+
}
153
+
154
+
// if all checks pass
155
+
return nil
156
+
}
+96
knotserver/xrpc/delete_repo.go
+96
knotserver/xrpc/delete_repo.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"os"
8
+
"path/filepath"
9
+
10
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"github.com/bluesky-social/indigo/xrpc"
13
+
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
"tangled.sh/tangled.sh/core/rbac"
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
+
)
18
+
19
+
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
20
+
l := x.Logger.With("handler", "DeleteRepo")
21
+
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
29
+
return
30
+
}
31
+
32
+
var data tangled.RepoDelete_Input
33
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
+
fail(xrpcerr.GenericError(err))
35
+
return
36
+
}
37
+
38
+
did := data.Did
39
+
name := data.Name
40
+
rkey := data.Rkey
41
+
42
+
if did == "" || name == "" {
43
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
44
+
return
45
+
}
46
+
47
+
ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String())
48
+
if err != nil || ident.Handle.IsInvalidHandle() {
49
+
fail(xrpcerr.GenericError(err))
50
+
return
51
+
}
52
+
53
+
xrpcc := xrpc.Client{
54
+
Host: ident.PDSEndpoint(),
55
+
}
56
+
57
+
// ensure that the record does not exists
58
+
_, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
59
+
if err == nil {
60
+
fail(xrpcerr.RecordExistsError(rkey))
61
+
return
62
+
}
63
+
64
+
relativeRepoPath := filepath.Join(did, name)
65
+
isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath)
66
+
if err != nil {
67
+
fail(xrpcerr.GenericError(err))
68
+
return
69
+
}
70
+
if !isDeleteAllowed {
71
+
fail(xrpcerr.AccessControlError(actorDid.String()))
72
+
return
73
+
}
74
+
75
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
76
+
if err != nil {
77
+
fail(xrpcerr.GenericError(err))
78
+
return
79
+
}
80
+
81
+
err = os.RemoveAll(repoPath)
82
+
if err != nil {
83
+
l.Error("deleting repo", "error", err.Error())
84
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
85
+
return
86
+
}
87
+
88
+
err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath)
89
+
if err != nil {
90
+
l.Error("failed to delete repo from enforcer", "error", err.Error())
91
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
92
+
return
93
+
}
94
+
95
+
w.WriteHeader(http.StatusOK)
96
+
}
+111
knotserver/xrpc/fork_status.go
+111
knotserver/xrpc/fork_status.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"path/filepath"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/knotserver/git"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/types"
15
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
)
17
+
18
+
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
19
+
l := x.Logger.With("handler", "ForkStatus")
20
+
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
28
+
return
29
+
}
30
+
31
+
var data tangled.RepoForkStatus_Input
32
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
+
fail(xrpcerr.GenericError(err))
34
+
return
35
+
}
36
+
37
+
did := data.Did
38
+
source := data.Source
39
+
branch := data.Branch
40
+
hiddenRef := data.HiddenRef
41
+
42
+
if did == "" || source == "" || branch == "" || hiddenRef == "" {
43
+
fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required")))
44
+
return
45
+
}
46
+
47
+
var name string
48
+
if data.Name != "" {
49
+
name = data.Name
50
+
} else {
51
+
name = filepath.Base(source)
52
+
}
53
+
54
+
relativeRepoPath := filepath.Join(did, name)
55
+
56
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
57
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
58
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
59
+
return
60
+
}
61
+
62
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
63
+
if err != nil {
64
+
fail(xrpcerr.GenericError(err))
65
+
return
66
+
}
67
+
68
+
gr, err := git.PlainOpen(repoPath)
69
+
if err != nil {
70
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
71
+
return
72
+
}
73
+
74
+
forkCommit, err := gr.ResolveRevision(branch)
75
+
if err != nil {
76
+
l.Error("error resolving ref revision", "msg", err.Error())
77
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err)))
78
+
return
79
+
}
80
+
81
+
sourceCommit, err := gr.ResolveRevision(hiddenRef)
82
+
if err != nil {
83
+
l.Error("error resolving hidden ref revision", "msg", err.Error())
84
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err)))
85
+
return
86
+
}
87
+
88
+
status := types.UpToDate
89
+
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
90
+
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
91
+
if err != nil {
92
+
l.Error("error checking ancestor relationship", "error", err.Error())
93
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err)))
94
+
return
95
+
}
96
+
97
+
if isAncestor {
98
+
status = types.FastForwardable
99
+
} else {
100
+
status = types.Conflict
101
+
}
102
+
}
103
+
104
+
response := tangled.RepoForkStatus_Output{
105
+
Status: int64(status),
106
+
}
107
+
108
+
w.Header().Set("Content-Type", "application/json")
109
+
w.WriteHeader(http.StatusOK)
110
+
json.NewEncoder(w).Encode(response)
111
+
}
+73
knotserver/xrpc/fork_sync.go
+73
knotserver/xrpc/fork_sync.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"path/filepath"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/knotserver/git"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
+
)
16
+
17
+
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
18
+
l := x.Logger.With("handler", "ForkSync")
19
+
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
27
+
return
28
+
}
29
+
30
+
var data tangled.RepoForkSync_Input
31
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
+
fail(xrpcerr.GenericError(err))
33
+
return
34
+
}
35
+
36
+
did := data.Did
37
+
name := data.Name
38
+
branch := data.Branch
39
+
40
+
if did == "" || name == "" {
41
+
fail(xrpcerr.GenericError(fmt.Errorf("did, name are required")))
42
+
return
43
+
}
44
+
45
+
relativeRepoPath := filepath.Join(did, name)
46
+
47
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
48
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
49
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
50
+
return
51
+
}
52
+
53
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
54
+
if err != nil {
55
+
fail(xrpcerr.GenericError(err))
56
+
return
57
+
}
58
+
59
+
gr, err := git.Open(repoPath, branch)
60
+
if err != nil {
61
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
62
+
return
63
+
}
64
+
65
+
err = gr.Sync()
66
+
if err != nil {
67
+
l.Error("error syncing repo fork", "error", err.Error())
68
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
69
+
return
70
+
}
71
+
72
+
w.WriteHeader(http.StatusOK)
73
+
}
+112
knotserver/xrpc/merge.go
+112
knotserver/xrpc/merge.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"errors"
6
+
"fmt"
7
+
"net/http"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/knotserver/git"
13
+
"tangled.sh/tangled.sh/core/patchutil"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.sh/tangled.sh/core/types"
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
+
)
18
+
19
+
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
20
+
l := x.Logger.With("handler", "Merge")
21
+
fail := func(e xrpcerr.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(xrpcerr.MissingActorDidError)
29
+
return
30
+
}
31
+
32
+
var data tangled.RepoMerge_Input
33
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
+
fail(xrpcerr.GenericError(err))
35
+
return
36
+
}
37
+
38
+
did := data.Did
39
+
name := data.Name
40
+
41
+
if did == "" || name == "" {
42
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
43
+
return
44
+
}
45
+
46
+
relativeRepoPath, err := securejoin.SecureJoin(did, name)
47
+
if err != nil {
48
+
fail(xrpcerr.GenericError(err))
49
+
return
50
+
}
51
+
52
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
53
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
54
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
55
+
return
56
+
}
57
+
58
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
59
+
if err != nil {
60
+
fail(xrpcerr.GenericError(err))
61
+
return
62
+
}
63
+
64
+
gr, err := git.Open(repoPath, data.Branch)
65
+
if err != nil {
66
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
67
+
return
68
+
}
69
+
70
+
mo := &git.MergeOptions{}
71
+
if data.AuthorName != nil {
72
+
mo.AuthorName = *data.AuthorName
73
+
}
74
+
if data.AuthorEmail != nil {
75
+
mo.AuthorEmail = *data.AuthorEmail
76
+
}
77
+
if data.CommitBody != nil {
78
+
mo.CommitBody = *data.CommitBody
79
+
}
80
+
if data.CommitMessage != nil {
81
+
mo.CommitMessage = *data.CommitMessage
82
+
}
83
+
84
+
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
85
+
86
+
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
87
+
if err != nil {
88
+
var mergeErr *git.ErrMerge
89
+
if errors.As(err, &mergeErr) {
90
+
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
91
+
for i, conflict := range mergeErr.Conflicts {
92
+
conflicts[i] = types.ConflictInfo{
93
+
Filename: conflict.Filename,
94
+
Reason: conflict.Reason,
95
+
}
96
+
}
97
+
98
+
conflictErr := xrpcerr.NewXrpcError(
99
+
xrpcerr.WithTag("MergeConflict"),
100
+
xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)),
101
+
)
102
+
writeError(w, conflictErr, http.StatusConflict)
103
+
return
104
+
} else {
105
+
l.Error("failed to merge", "error", err.Error())
106
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
107
+
return
108
+
}
109
+
}
110
+
111
+
w.WriteHeader(http.StatusOK)
112
+
}
+87
knotserver/xrpc/merge_check.go
+87
knotserver/xrpc/merge_check.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"errors"
6
+
"fmt"
7
+
"net/http"
8
+
9
+
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"tangled.sh/tangled.sh/core/api/tangled"
11
+
"tangled.sh/tangled.sh/core/knotserver/git"
12
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
)
14
+
15
+
func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
16
+
l := x.Logger.With("handler", "MergeCheck")
17
+
fail := func(e xrpcerr.XrpcError) {
18
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
19
+
writeError(w, e, http.StatusBadRequest)
20
+
}
21
+
22
+
var data tangled.RepoMergeCheck_Input
23
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
24
+
fail(xrpcerr.GenericError(err))
25
+
return
26
+
}
27
+
28
+
did := data.Did
29
+
name := data.Name
30
+
31
+
if did == "" || name == "" {
32
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
33
+
return
34
+
}
35
+
36
+
relativeRepoPath, err := securejoin.SecureJoin(did, name)
37
+
if err != nil {
38
+
fail(xrpcerr.GenericError(err))
39
+
return
40
+
}
41
+
42
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
43
+
if err != nil {
44
+
fail(xrpcerr.GenericError(err))
45
+
return
46
+
}
47
+
48
+
gr, err := git.Open(repoPath, data.Branch)
49
+
if err != nil {
50
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
51
+
return
52
+
}
53
+
54
+
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
55
+
56
+
response := tangled.RepoMergeCheck_Output{
57
+
Is_conflicted: false,
58
+
}
59
+
60
+
if err != nil {
61
+
var mergeErr *git.ErrMerge
62
+
if errors.As(err, &mergeErr) {
63
+
response.Is_conflicted = true
64
+
65
+
conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts))
66
+
for i, conflict := range mergeErr.Conflicts {
67
+
conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{
68
+
Filename: conflict.Filename,
69
+
Reason: conflict.Reason,
70
+
}
71
+
}
72
+
response.Conflicts = conflicts
73
+
74
+
if mergeErr.Message != "" {
75
+
response.Message = &mergeErr.Message
76
+
}
77
+
} else {
78
+
response.Is_conflicted = true
79
+
errMsg := err.Error()
80
+
response.Error = &errMsg
81
+
}
82
+
}
83
+
84
+
w.Header().Set("Content-Type", "application/json")
85
+
w.WriteHeader(http.StatusOK)
86
+
json.NewEncoder(w).Encode(response)
87
+
}
-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
-
}
+12
-10
knotserver/xrpc/set_default_branch.go
+12
-10
knotserver/xrpc/set_default_branch.go
···
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
13
"tangled.sh/tangled.sh/core/knotserver/git"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
+
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
17
)
16
18
17
19
const ActorDid string = "ActorDid"
18
20
19
21
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
20
22
l := x.Logger
21
-
fail := func(e XrpcError) {
23
+
fail := func(e xrpcerr.XrpcError) {
22
24
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
25
writeError(w, e, http.StatusBadRequest)
24
26
}
25
27
26
28
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
29
if !ok {
28
-
fail(MissingActorDidError)
30
+
fail(xrpcerr.MissingActorDidError)
29
31
return
30
32
}
31
33
32
34
var data tangled.RepoSetDefaultBranch_Input
33
35
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(GenericError(err))
36
+
fail(xrpcerr.GenericError(err))
35
37
return
36
38
}
37
39
38
40
// unfortunately we have to resolve repo-at here
39
41
repoAt, err := syntax.ParseATURI(data.Repo)
40
42
if err != nil {
41
-
fail(InvalidRepoError(data.Repo))
43
+
fail(xrpcerr.InvalidRepoError(data.Repo))
42
44
return
43
45
}
44
46
45
47
// resolve this aturi to extract the repo record
46
48
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
49
if err != nil || ident.Handle.IsInvalidHandle() {
48
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
50
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
51
return
50
52
}
51
53
52
54
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
55
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
56
if err != nil {
55
-
fail(GenericError(err))
57
+
fail(xrpcerr.GenericError(err))
56
58
return
57
59
}
58
60
59
61
repo := resp.Value.Val.(*tangled.Repo)
60
62
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
61
63
if err != nil {
62
-
fail(GenericError(err))
64
+
fail(xrpcerr.GenericError(err))
63
65
return
64
66
}
65
67
66
68
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
69
l.Error("insufficent permissions", "did", actorDid.String())
68
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
70
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
71
return
70
72
}
71
73
72
74
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
75
gr, err := git.PlainOpen(path)
74
76
if err != nil {
75
-
fail(InvalidRepoError(data.Repo))
77
+
fail(xrpcerr.GenericError(err))
76
78
return
77
79
}
78
80
79
81
err = gr.SetDefaultBranch(data.DefaultBranch)
80
82
if err != nil {
81
83
l.Error("setting default branch", "error", err.Error())
82
-
writeError(w, GitError(err), http.StatusInternalServerError)
84
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
83
85
return
84
86
}
85
87
+60
knotserver/xrpc/xrpc.go
+60
knotserver/xrpc/xrpc.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"log/slog"
6
+
"net/http"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/idresolver"
10
+
"tangled.sh/tangled.sh/core/jetstream"
11
+
"tangled.sh/tangled.sh/core/knotserver/config"
12
+
"tangled.sh/tangled.sh/core/knotserver/db"
13
+
"tangled.sh/tangled.sh/core/notifier"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
17
+
18
+
"github.com/go-chi/chi/v5"
19
+
)
20
+
21
+
type Xrpc struct {
22
+
Config *config.Config
23
+
Db *db.DB
24
+
Ingester *jetstream.JetstreamClient
25
+
Enforcer *rbac.Enforcer
26
+
Logger *slog.Logger
27
+
Notifier *notifier.Notifier
28
+
Resolver *idresolver.Resolver
29
+
ServiceAuth *serviceauth.ServiceAuth
30
+
}
31
+
32
+
func (x *Xrpc) Router() http.Handler {
33
+
r := chi.NewRouter()
34
+
35
+
r.Group(func(r chi.Router) {
36
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
37
+
38
+
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
39
+
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
40
+
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
41
+
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
42
+
r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
43
+
r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
44
+
r.Post("/"+tangled.RepoMergeNSID, x.Merge)
45
+
})
46
+
47
+
// merge check is an open endpoint
48
+
//
49
+
// TODO: should we constrain this more?
50
+
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
51
+
// - use ETags on clients to keep requests to a minimum
52
+
r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
53
+
return r
54
+
}
55
+
56
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
57
+
w.Header().Set("Content-Type", "application/json")
58
+
w.WriteHeader(status)
59
+
json.NewEncoder(w).Encode(e)
60
+
}
+59
-52
lexicons/git/refUpdate.json
+59
-52
lexicons/git/refUpdate.json
···
51
51
"maxLength": 40
52
52
},
53
53
"meta": {
54
-
"type": "object",
55
-
"required": [
56
-
"isDefaultRef",
57
-
"commitCount"
58
-
],
59
-
"properties": {
60
-
"isDefaultRef": {
61
-
"type": "boolean",
62
-
"default": "false"
63
-
},
64
-
"langBreakdown": {
65
-
"type": "object",
66
-
"properties": {
67
-
"inputs": {
68
-
"type": "array",
69
-
"items": {
70
-
"type": "ref",
71
-
"ref": "#pair"
72
-
}
73
-
}
74
-
}
75
-
},
76
-
"commitCount": {
77
-
"type": "object",
78
-
"required": [],
79
-
"properties": {
80
-
"byEmail": {
81
-
"type": "array",
82
-
"items": {
83
-
"type": "object",
84
-
"required": [
85
-
"email",
86
-
"count"
87
-
],
88
-
"properties": {
89
-
"email": {
90
-
"type": "string"
91
-
},
92
-
"count": {
93
-
"type": "integer"
94
-
}
95
-
}
96
-
}
97
-
}
98
-
}
99
-
}
100
-
}
54
+
"type": "ref",
55
+
"ref": "#meta"
56
+
}
57
+
}
58
+
}
59
+
},
60
+
"meta": {
61
+
"type": "object",
62
+
"required": ["isDefaultRef", "commitCount"],
63
+
"properties": {
64
+
"isDefaultRef": {
65
+
"type": "boolean",
66
+
"default": false
67
+
},
68
+
"langBreakdown": {
69
+
"type": "ref",
70
+
"ref": "#langBreakdown"
71
+
},
72
+
"commitCount": {
73
+
"type": "ref",
74
+
"ref": "#commitCountBreakdown"
75
+
}
76
+
}
77
+
},
78
+
"langBreakdown": {
79
+
"type": "object",
80
+
"properties": {
81
+
"inputs": {
82
+
"type": "array",
83
+
"items": {
84
+
"type": "ref",
85
+
"ref": "#individualLanguageSize"
101
86
}
102
87
}
103
88
}
104
89
},
105
-
"pair": {
90
+
"individualLanguageSize": {
106
91
"type": "object",
107
-
"required": [
108
-
"lang",
109
-
"size"
110
-
],
92
+
"required": ["lang", "size"],
111
93
"properties": {
112
94
"lang": {
113
95
"type": "string"
114
96
},
115
97
"size": {
98
+
"type": "integer"
99
+
}
100
+
}
101
+
},
102
+
"commitCountBreakdown": {
103
+
"type": "object",
104
+
"required": [],
105
+
"properties": {
106
+
"byEmail": {
107
+
"type": "array",
108
+
"items": {
109
+
"type": "ref",
110
+
"ref": "#individualEmailCommitCount"
111
+
}
112
+
}
113
+
}
114
+
},
115
+
"individualEmailCommitCount": {
116
+
"type": "object",
117
+
"required": ["email", "count"],
118
+
"properties": {
119
+
"email": {
120
+
"type": "string"
121
+
},
122
+
"count": {
116
123
"type": "integer"
117
124
}
118
125
}
+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
-14
lexicons/issue/issue.json
+1
-14
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", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
-
},
27
-
"owner": {
28
-
"type": "string",
29
-
"format": "did"
30
17
},
31
18
"title": {
32
19
"type": "string"
+24
lexicons/knot/knot.json
+24
lexicons/knot/knot.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot",
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
+
}
+7
-63
lexicons/pipeline/pipeline.json
+7
-63
lexicons/pipeline/pipeline.json
···
149
149
"type": "object",
150
150
"required": [
151
151
"name",
152
-
"dependencies",
153
-
"steps",
154
-
"environment",
155
-
"clone"
152
+
"engine",
153
+
"clone",
154
+
"raw"
156
155
],
157
156
"properties": {
158
157
"name": {
159
158
"type": "string"
160
159
},
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
-
}
160
+
"engine": {
161
+
"type": "string"
181
162
},
182
163
"clone": {
183
164
"type": "ref",
184
165
"ref": "#cloneOpts"
185
-
}
186
-
}
187
-
},
188
-
"dependency": {
189
-
"type": "object",
190
-
"required": [
191
-
"registry",
192
-
"packages"
193
-
],
194
-
"properties": {
195
-
"registry": {
166
+
},
167
+
"raw": {
196
168
"type": "string"
197
-
},
198
-
"packages": {
199
-
"type": "array",
200
-
"items": {
201
-
"type": "string"
202
-
}
203
169
}
204
170
}
205
171
},
···
219
185
},
220
186
"submodules": {
221
187
"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
188
}
245
189
}
246
190
},
-11
lexicons/pulls/comment.json
-11
lexicons/pulls/comment.json
+20
-12
lexicons/pulls/pull.json
+20
-12
lexicons/pulls/pull.json
···
10
10
"record": {
11
11
"type": "object",
12
12
"required": [
13
-
"targetRepo",
14
-
"targetBranch",
15
-
"pullId",
13
+
"target",
16
14
"title",
17
15
"patch",
18
16
"createdAt"
19
17
],
20
18
"properties": {
21
-
"targetRepo": {
22
-
"type": "string",
23
-
"format": "at-uri"
24
-
},
25
-
"targetBranch": {
26
-
"type": "string"
27
-
},
28
-
"pullId": {
29
-
"type": "integer"
19
+
"target": {
20
+
"type": "ref",
21
+
"ref": "#target"
30
22
},
31
23
"title": {
32
24
"type": "string"
···
45
37
"type": "string",
46
38
"format": "datetime"
47
39
}
40
+
}
41
+
}
42
+
},
43
+
"target": {
44
+
"type": "object",
45
+
"required": [
46
+
"repo",
47
+
"branch"
48
+
],
49
+
"properties": {
50
+
"repo": {
51
+
"type": "string",
52
+
"format": "at-uri"
53
+
},
54
+
"branch": {
55
+
"type": "string"
48
56
}
49
57
}
50
58
},
+33
lexicons/repo/create.json
+33
lexicons/repo/create.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.create",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Create a new repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"rkey"
14
+
],
15
+
"properties": {
16
+
"rkey": {
17
+
"type": "string",
18
+
"description": "Rkey of the repository record"
19
+
},
20
+
"defaultBranch": {
21
+
"type": "string",
22
+
"description": "Default branch to push to"
23
+
},
24
+
"source": {
25
+
"type": "string",
26
+
"description": "A source URL to clone from, populate this when forking or importing a repository."
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
+32
lexicons/repo/delete.json
+32
lexicons/repo/delete.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.delete",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Delete a repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "rkey"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the repository owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the repository to delete"
22
+
},
23
+
"rkey": {
24
+
"type": "string",
25
+
"description": "Rkey of the repository record"
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
32
+
}
+53
lexicons/repo/forkStatus.json
+53
lexicons/repo/forkStatus.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.forkStatus",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Check fork status relative to upstream source",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "source", "branch", "hiddenRef"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the fork owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the forked repository"
22
+
},
23
+
"source": {
24
+
"type": "string",
25
+
"description": "Source repository URL"
26
+
},
27
+
"branch": {
28
+
"type": "string",
29
+
"description": "Branch to check status for"
30
+
},
31
+
"hiddenRef": {
32
+
"type": "string",
33
+
"description": "Hidden ref to use for comparison"
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"output": {
39
+
"encoding": "application/json",
40
+
"schema": {
41
+
"type": "object",
42
+
"required": ["status"],
43
+
"properties": {
44
+
"status": {
45
+
"type": "integer",
46
+
"description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch"
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
+42
lexicons/repo/forkSync.json
+42
lexicons/repo/forkSync.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.forkSync",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Sync a forked repository with its upstream source",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"did",
14
+
"source",
15
+
"name",
16
+
"branch"
17
+
],
18
+
"properties": {
19
+
"did": {
20
+
"type": "string",
21
+
"format": "did",
22
+
"description": "DID of the fork owner"
23
+
},
24
+
"source": {
25
+
"type": "string",
26
+
"format": "at-uri",
27
+
"description": "AT-URI of the source repository"
28
+
},
29
+
"name": {
30
+
"type": "string",
31
+
"description": "Name of the forked repository"
32
+
},
33
+
"branch": {
34
+
"type": "string",
35
+
"description": "Branch to sync"
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
41
+
}
42
+
}
+52
lexicons/repo/merge.json
+52
lexicons/repo/merge.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.merge",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Merge a patch into a repository branch",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "patch", "branch"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the repository owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the repository"
22
+
},
23
+
"patch": {
24
+
"type": "string",
25
+
"description": "Patch content to merge"
26
+
},
27
+
"branch": {
28
+
"type": "string",
29
+
"description": "Target branch to merge into"
30
+
},
31
+
"authorName": {
32
+
"type": "string",
33
+
"description": "Author name for the merge commit"
34
+
},
35
+
"authorEmail": {
36
+
"type": "string",
37
+
"description": "Author email for the merge commit"
38
+
},
39
+
"commitBody": {
40
+
"type": "string",
41
+
"description": "Additional commit message body"
42
+
},
43
+
"commitMessage": {
44
+
"type": "string",
45
+
"description": "Merge commit message"
46
+
}
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
+79
lexicons/repo/mergeCheck.json
+79
lexicons/repo/mergeCheck.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.mergeCheck",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Check if a merge is possible between two branches",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "patch", "branch"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the repository owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the repository"
22
+
},
23
+
"patch": {
24
+
"type": "string",
25
+
"description": "Patch or pull request to check for merge conflicts"
26
+
},
27
+
"branch": {
28
+
"type": "string",
29
+
"description": "Target branch to merge into"
30
+
}
31
+
}
32
+
}
33
+
},
34
+
"output": {
35
+
"encoding": "application/json",
36
+
"schema": {
37
+
"type": "object",
38
+
"required": ["is_conflicted"],
39
+
"properties": {
40
+
"is_conflicted": {
41
+
"type": "boolean",
42
+
"description": "Whether the merge has conflicts"
43
+
},
44
+
"conflicts": {
45
+
"type": "array",
46
+
"description": "List of files with merge conflicts",
47
+
"items": {
48
+
"type": "ref",
49
+
"ref": "#conflictInfo"
50
+
}
51
+
},
52
+
"message": {
53
+
"type": "string",
54
+
"description": "Additional message about the merge check"
55
+
},
56
+
"error": {
57
+
"type": "string",
58
+
"description": "Error message if check failed"
59
+
}
60
+
}
61
+
}
62
+
}
63
+
},
64
+
"conflictInfo": {
65
+
"type": "object",
66
+
"required": ["filename", "reason"],
67
+
"properties": {
68
+
"filename": {
69
+
"type": "string",
70
+
"description": "Name of the conflicted file"
71
+
},
72
+
"reason": {
73
+
"type": "string",
74
+
"description": "Reason for the conflict"
75
+
}
76
+
}
77
+
}
78
+
}
79
+
}
+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)})
+8
-2
nix/gomod2nix.toml
+8
-2
nix/gomod2nix.toml
···
181
181
[mod."github.com/gorilla/css"]
182
182
version = "v1.0.1"
183
183
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
184
+
[mod."github.com/gorilla/feeds"]
185
+
version = "v1.2.0"
186
+
hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk="
184
187
[mod."github.com/gorilla/securecookie"]
185
188
version = "v1.1.2"
186
189
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
···
423
426
version = "v0.3.1"
424
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
425
428
[mod."github.com/yuin/goldmark"]
426
-
version = "v1.4.13"
427
-
hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI="
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="
428
434
[mod."gitlab.com/yawning/secp256k1-voi"]
429
435
version = "v0.0.0-20230925100816-f2616030848b"
430
436
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
+14
nix/modules/appview.nix
+14
nix/modules/appview.nix
···
27
27
default = "00000000000000000000000000000000";
28
28
description = "Cookie secret";
29
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
+
};
30
43
};
31
44
};
32
45
···
39
52
ListenStream = "0.0.0.0:${toString cfg.port}";
40
53
ExecStart = "${cfg.package}/bin/appview";
41
54
Restart = "always";
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
42
56
};
43
57
44
58
environment = {
+32
-29
nix/modules/knot.nix
+32
-29
nix/modules/knot.nix
···
93
93
description = "Internal address for inter-service communication";
94
94
};
95
95
96
-
secretFile = mkOption {
97
-
type = lib.types.path;
98
-
example = "KNOT_SERVER_SECRET=<hash>";
99
-
description = "File containing secret key provided by appview (required)";
96
+
owner = mkOption {
97
+
type = types.str;
98
+
example = "did:plc:qfpnj4og54vl56wngdriaxug";
99
+
description = "DID of owner (required)";
100
100
};
101
101
102
102
dbPath = mkOption {
···
126
126
cfg.package
127
127
];
128
128
129
-
system.activationScripts.gitConfig = let
130
-
setMotd =
131
-
if cfg.motdFile != null && cfg.motd != null
132
-
then throw "motdFile and motd cannot be both set"
133
-
else ''
134
-
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
-
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
136
-
'';
137
-
in ''
138
-
mkdir -p "${cfg.repo.scanPath}"
139
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
140
-
141
-
mkdir -p "${cfg.stateDir}/.config/git"
142
-
cat > "${cfg.stateDir}/.config/git/config" << EOF
143
-
[user]
144
-
name = Git User
145
-
email = git@example.com
146
-
[receive]
147
-
advertisePushOptions = true
148
-
EOF
149
-
${setMotd}
150
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
151
-
'';
152
-
153
129
users.users.${cfg.gitUser} = {
154
130
isSystemUser = true;
155
131
useDefaultShell = true;
···
185
161
description = "knot service";
186
162
after = ["network.target" "sshd.service"];
187
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
+
188
190
serviceConfig = {
189
191
User = cfg.gitUser;
192
+
PermissionsStartOnly = true;
190
193
WorkingDirectory = cfg.stateDir;
191
194
Environment = [
192
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
···
196
199
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
197
200
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
198
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
202
+
"KNOT_SERVER_OWNER=${cfg.server.owner}"
199
203
];
200
-
EnvironmentFile = cfg.server.secretFile;
201
204
ExecStart = "${cfg.package}/bin/knot server";
202
205
Restart = "always";
203
206
};
+18
-2
nix/modules/spindle.nix
+18
-2
nix/modules/spindle.nix
···
55
55
description = "DID of owner (required)";
56
56
};
57
57
58
+
maxJobCount = mkOption {
59
+
type = types.int;
60
+
default = 2;
61
+
example = 5;
62
+
description = "Maximum number of concurrent jobs to run";
63
+
};
64
+
65
+
queueSize = mkOption {
66
+
type = types.int;
67
+
default = 100;
68
+
example = 100;
69
+
description = "Maximum number of jobs queue up";
70
+
};
71
+
58
72
secrets = {
59
73
provider = mkOption {
60
74
type = types.str;
···
108
122
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
109
123
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
110
124
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
+
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
126
+
"SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}"
111
127
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
128
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
129
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
114
-
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
115
-
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
130
+
"SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
131
+
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
116
132
];
117
133
ExecStart = "${cfg.package}/bin/spindle";
118
134
Restart = "always";
+8
-2
nix/pkgs/appview-static-files.nix
+8
-2
nix/pkgs/appview-static-files.nix
···
9
9
tailwindcss,
10
10
src,
11
11
}:
12
-
runCommandLocal "appview-static-files" {} ''
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
+
} ''
13
19
mkdir -p $out/{fonts,icons} && cd $out
14
20
cp -f ${htmx-src} htmx.min.js
15
21
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
16
22
cp -rf ${lucide-src}/*.svg icons/
17
23
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
18
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
19
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
25
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
20
26
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
21
27
# for whatever reason (produces broken css), so we are doing this instead
22
28
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+8
-3
nix/pkgs/genjwks.nix
+8
-3
nix/pkgs/genjwks.nix
···
1
1
{
2
-
src,
3
2
buildGoApplication,
4
3
modules,
5
4
}:
6
5
buildGoApplication {
7
6
pname = "genjwks";
8
7
version = "0.1.0";
9
-
inherit src modules;
10
-
subPackages = ["cmd/genjwks"];
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;
11
16
doCheck = false;
12
17
CGO_ENABLED = 0;
13
18
}
+57
-16
nix/vm.nix
+57
-16
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
3
system,
4
+
hostSystem,
4
5
self,
5
6
}: let
6
7
envVar = name: let
···
16
17
self.nixosModules.knot
17
18
self.nixosModules.spindle
18
19
({
20
+
lib,
19
21
config,
20
22
pkgs,
21
23
...
22
24
}: {
23
-
nixos-shell = {
24
-
inheritPath = false;
25
-
mounts = {
26
-
mountHome = false;
27
-
mountNixProfile = false;
28
-
};
29
-
};
30
-
virtualisation = {
25
+
virtualisation.vmVariant.virtualisation = {
26
+
host.pkgs = import nixpkgs {system = hostSystem;};
27
+
28
+
graphics = false;
31
29
memorySize = 2048;
32
30
diskSize = 10 * 1024;
33
31
cores = 2;
···
51
49
guest.port = 6555;
52
50
}
53
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
+
};
54
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
+
time.timeZone = "Europe/London";
55
74
services.getty.autologinUser = "root";
56
75
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
57
-
systemd.tmpfiles.rules = let
58
-
u = config.services.tangled-knot.gitUser;
59
-
g = config.services.tangled-knot.gitUser;
60
-
in [
61
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
62
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
63
-
];
64
76
services.tangled-knot = {
65
77
enable = true;
66
78
motd = "Welcome to the development knot!\n";
67
79
server = {
68
-
secretFile = "/var/lib/knot/secret";
80
+
owner = envVar "TANGLED_VM_KNOT_OWNER";
69
81
hostname = "localhost:6000";
70
82
listenAddr = "0.0.0.0:6000";
71
83
};
···
77
89
hostname = "localhost:6555";
78
90
listenAddr = "0.0.0.0:6555";
79
91
dev = true;
92
+
queueSize = 100;
93
+
maxJobCount = 2;
80
94
secrets = {
81
95
provider = "sqlite";
82
96
};
83
97
};
98
+
};
99
+
users = {
100
+
# So we don't have to deal with permission clashing between
101
+
# blank disk VMs and existing state
102
+
users.${config.services.tangled-knot.gitUser}.uid = 666;
103
+
groups.${config.services.tangled-knot.gitUser}.gid = 666;
104
+
105
+
# TODO: separate spindle user
106
+
};
107
+
systemd.services = let
108
+
mkDataSyncScripts = source: target: {
109
+
enableStrictShellChecks = true;
110
+
111
+
preStart = lib.mkBefore ''
112
+
mkdir -p ${target}
113
+
${lib.getExe pkgs.rsync} -a ${source}/ ${target}
114
+
'';
115
+
116
+
postStop = lib.mkAfter ''
117
+
${lib.getExe pkgs.rsync} -a ${target}/ ${source}
118
+
'';
119
+
120
+
serviceConfig.PermissionsStartOnly = true;
121
+
};
122
+
in {
123
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
124
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
84
125
};
85
126
})
86
127
];
+14
-1
rbac/rbac.go
+14
-1
rbac/rbac.go
···
43
43
return nil, err
44
44
}
45
45
46
-
db, err := sql.Open("sqlite3", path)
46
+
db, err := sql.Open("sqlite3", path+"?_foreign_keys=1")
47
47
if err != nil {
48
48
return nil, err
49
49
}
···
97
97
func (e *Enforcer) RemoveSpindle(spindle string) error {
98
98
spindle = intoSpindle(spindle)
99
99
_, err := e.E.DeleteDomains(spindle)
100
+
return err
101
+
}
102
+
103
+
func (e *Enforcer) RemoveKnot(knot string) error {
104
+
_, err := e.E.DeleteDomains(knot)
100
105
return err
101
106
}
102
107
···
270
275
271
276
func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) {
272
277
return e.isInviteAllowed(user, intoSpindle(domain))
278
+
}
279
+
280
+
func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) {
281
+
return e.E.Enforce(user, domain, domain, "repo:create")
282
+
}
283
+
284
+
func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) {
285
+
return e.E.Enforce(user, domain, repo, "repo:delete")
273
286
}
274
287
275
288
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+1
-1
rbac/rbac_test.go
+1
-1
rbac/rbac_test.go
+6
-4
spindle/config/config.go
+6
-4
spindle/config/config.go
···
16
16
Dev bool `env:"DEV, default=false"`
17
17
Owner string `env:"OWNER, required"`
18
18
Secrets Secrets `env:",prefix=SECRETS_"`
19
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
20
+
QueueSize int `env:"QUEUE_SIZE, default=100"`
21
+
MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
19
22
}
20
23
21
24
func (s Server) Did() syntax.DID {
···
32
35
Mount string `env:"MOUNT, default=spindle"`
33
36
}
34
37
35
-
type Pipelines struct {
38
+
type NixeryPipelines struct {
36
39
Nixery string `env:"NIXERY, default=nixery.tangled.sh"`
37
40
WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"`
38
-
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
39
41
}
40
42
41
43
type Config struct {
42
-
Server Server `env:",prefix=SPINDLE_SERVER_"`
43
-
Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"`
44
+
Server Server `env:",prefix=SPINDLE_SERVER_"`
45
+
NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"`
44
46
}
45
47
46
48
func Load(ctx context.Context) (*Config, error) {
+14
-10
spindle/db/db.go
+14
-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
}
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 _jetstream (
30
34
id integer primary key autoincrement,
31
35
last_time_us integer not null
-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
-
}
+68
-415
spindle/engine/engine.go
+68
-415
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
9
securejoin "github.com/cyphar/filepath-securejoin"
15
-
"github.com/docker/docker/api/types/container"
16
-
"github.com/docker/docker/api/types/image"
17
-
"github.com/docker/docker/api/types/mount"
18
-
"github.com/docker/docker/api/types/network"
19
-
"github.com/docker/docker/api/types/volume"
20
-
"github.com/docker/docker/client"
21
-
"github.com/docker/docker/pkg/stdcopy"
22
10
"golang.org/x/sync/errgroup"
23
-
"tangled.sh/tangled.sh/core/log"
24
11
"tangled.sh/tangled.sh/core/notifier"
25
12
"tangled.sh/tangled.sh/core/spindle/config"
26
13
"tangled.sh/tangled.sh/core/spindle/db"
···
28
15
"tangled.sh/tangled.sh/core/spindle/secrets"
29
16
)
30
17
31
-
const (
32
-
workspaceDir = "/tangled/workspace"
18
+
var (
19
+
ErrTimedOut = errors.New("timed out")
20
+
ErrWorkflowFailed = errors.New("workflow failed")
33
21
)
34
22
35
-
type cleanupFunc func(context.Context) error
36
-
37
-
type Engine struct {
38
-
docker client.APIClient
39
-
l *slog.Logger
40
-
db *db.DB
41
-
n *notifier.Notifier
42
-
cfg *config.Config
43
-
vault secrets.Manager
44
-
45
-
cleanupMu sync.Mutex
46
-
cleanup map[string][]cleanupFunc
47
-
}
48
-
49
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
50
-
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
51
-
if err != nil {
52
-
return nil, err
53
-
}
54
-
55
-
l := log.FromContext(ctx).With("component", "spindle")
56
-
57
-
e := &Engine{
58
-
docker: dcli,
59
-
l: l,
60
-
db: db,
61
-
n: n,
62
-
cfg: cfg,
63
-
vault: vault,
64
-
}
65
-
66
-
e.cleanup = make(map[string][]cleanupFunc)
67
-
68
-
return e, nil
69
-
}
70
-
71
-
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
72
-
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
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)
73
25
74
26
// extract secrets
75
27
var allSecrets []secrets.UnlockedSecret
76
28
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
77
-
if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
29
+
if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
78
30
allSecrets = res
79
31
}
80
32
}
81
33
82
-
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
83
-
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
84
-
if err != nil {
85
-
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
86
-
workflowTimeout = 5 * time.Minute
87
-
}
88
-
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
89
-
90
34
eg, ctx := errgroup.WithContext(ctx)
91
-
for _, w := range pipeline.Workflows {
92
-
eg.Go(func() error {
93
-
wid := models.WorkflowId{
94
-
PipelineId: pipelineId,
95
-
Name: w.Name,
96
-
}
97
-
98
-
err := e.db.StatusRunning(wid, e.n)
99
-
if err != nil {
100
-
return err
101
-
}
35
+
for eng, wfs := range pipeline.Workflows {
36
+
workflowTimeout := eng.WorkflowTimeout()
37
+
l.Info("using workflow timeout", "timeout", workflowTimeout)
102
38
103
-
err = e.SetupWorkflow(ctx, wid)
104
-
if err != nil {
105
-
e.l.Error("setting up worklow", "wid", wid, "err", err)
106
-
return err
107
-
}
108
-
defer e.DestroyWorkflow(ctx, wid)
109
-
110
-
reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
111
-
if err != nil {
112
-
e.l.Error("pipeline image pull failed!", "image", w.Image, "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
+
}
113
45
114
-
err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
46
+
err := db.StatusRunning(wid, n)
115
47
if err != nil {
116
48
return err
117
49
}
118
50
119
-
return fmt.Errorf("pulling image: %w", err)
120
-
}
121
-
defer reader.Close()
122
-
io.Copy(os.Stdout, reader)
123
-
124
-
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
125
-
defer cancel()
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)
126
56
127
-
err = e.StartSteps(ctx, wid, w, allSecrets)
128
-
if err != nil {
129
-
if errors.Is(err, ErrTimedOut) {
130
-
dbErr := e.db.StatusTimeout(wid, e.n)
131
-
if dbErr != nil {
132
-
return dbErr
57
+
destroyErr := eng.DestroyWorkflow(ctx, wid)
58
+
if destroyErr != nil {
59
+
l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
133
60
}
134
-
} else {
135
-
dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n)
61
+
62
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
136
63
if dbErr != nil {
137
64
return dbErr
138
65
}
66
+
return err
139
67
}
68
+
defer eng.DestroyWorkflow(ctx, wid)
140
69
141
-
return fmt.Errorf("starting steps image: %w", err)
142
-
}
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
+
}
143
77
144
-
err = e.db.StatusSuccess(wid, e.n)
145
-
if err != nil {
146
-
return err
147
-
}
78
+
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
79
+
defer cancel()
148
80
149
-
return nil
150
-
})
151
-
}
81
+
for stepIdx, step := range w.Steps {
82
+
if wfLogger != nil {
83
+
ctl := wfLogger.ControlWriter(stepIdx, step)
84
+
ctl.Write([]byte(step.Name()))
85
+
}
152
86
153
-
if err = eg.Wait(); err != nil {
154
-
e.l.Error("failed to run one or more workflows", "err", err)
155
-
} else {
156
-
e.l.Error("successfully ran full pipeline")
157
-
}
158
-
}
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
+
}
159
100
160
-
// SetupWorkflow sets up a new network for the workflow and volumes for
161
-
// the workspace and Nix store. These are persisted across steps and are
162
-
// destroyed at the end of the workflow.
163
-
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error {
164
-
e.l.Info("setting up workflow", "workflow", wid)
101
+
return fmt.Errorf("starting steps image: %w", err)
102
+
}
103
+
}
165
104
166
-
_, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{
167
-
Name: workspaceVolume(wid),
168
-
Driver: "local",
169
-
})
170
-
if err != nil {
171
-
return err
172
-
}
173
-
e.registerCleanup(wid, func(ctx context.Context) error {
174
-
return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true)
175
-
})
176
-
177
-
_, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{
178
-
Name: nixVolume(wid),
179
-
Driver: "local",
180
-
})
181
-
if err != nil {
182
-
return err
183
-
}
184
-
e.registerCleanup(wid, func(ctx context.Context) error {
185
-
return e.docker.VolumeRemove(ctx, nixVolume(wid), true)
186
-
})
187
-
188
-
_, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
189
-
Driver: "bridge",
190
-
})
191
-
if err != nil {
192
-
return err
193
-
}
194
-
e.registerCleanup(wid, func(ctx context.Context) error {
195
-
return e.docker.NetworkRemove(ctx, networkName(wid))
196
-
})
105
+
err = db.StatusSuccess(wid, n)
106
+
if err != nil {
107
+
return err
108
+
}
197
109
198
-
return nil
199
-
}
200
-
201
-
// StartSteps starts all steps sequentially with the same base image.
202
-
// ONLY marks pipeline as failed if container's exit code is non-zero.
203
-
// All other errors are bubbled up.
204
-
// Fixed version of the step execution logic
205
-
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
206
-
workflowEnvs := ConstructEnvs(w.Environment)
207
-
for _, s := range secrets {
208
-
workflowEnvs.AddEnv(s.Key, s.Value)
209
-
}
210
-
211
-
for stepIdx, step := range w.Steps {
212
-
select {
213
-
case <-ctx.Done():
214
-
return ctx.Err()
215
-
default:
216
-
}
217
-
218
-
envs := append(EnvVars(nil), workflowEnvs...)
219
-
for k, v := range step.Environment {
220
-
envs.AddEnv(k, v)
221
-
}
222
-
envs.AddEnv("HOME", workspaceDir)
223
-
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
224
-
225
-
hostConfig := hostConfig(wid)
226
-
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
227
-
Image: w.Image,
228
-
Cmd: []string{"bash", "-c", step.Command},
229
-
WorkingDir: workspaceDir,
230
-
Tty: false,
231
-
Hostname: "spindle",
232
-
Env: envs.Slice(),
233
-
}, hostConfig, nil, nil, "")
234
-
defer e.DestroyStep(ctx, resp.ID)
235
-
if err != nil {
236
-
return fmt.Errorf("creating container: %w", err)
237
-
}
238
-
239
-
err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil)
240
-
if err != nil {
241
-
return fmt.Errorf("connecting network: %w", err)
242
-
}
243
-
244
-
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
245
-
if err != nil {
246
-
return err
247
-
}
248
-
e.l.Info("started container", "name", resp.ID, "step", step.Name)
249
-
250
-
// start tailing logs in background
251
-
tailDone := make(chan error, 1)
252
-
go func() {
253
-
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
254
-
}()
255
-
256
-
// wait for container completion or timeout
257
-
waitDone := make(chan struct{})
258
-
var state *container.State
259
-
var waitErr error
260
-
261
-
go func() {
262
-
defer close(waitDone)
263
-
state, waitErr = e.WaitStep(ctx, resp.ID)
264
-
}()
265
-
266
-
select {
267
-
case <-waitDone:
268
-
269
-
// wait for tailing to complete
270
-
<-tailDone
271
-
272
-
case <-ctx.Done():
273
-
e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name)
274
-
err = e.DestroyStep(context.Background(), resp.ID)
275
-
if err != nil {
276
-
e.l.Error("failed to destroy step", "container", resp.ID, "error", err)
277
-
}
278
-
279
-
// wait for both goroutines to finish
280
-
<-waitDone
281
-
<-tailDone
282
-
283
-
return ErrTimedOut
284
-
}
285
-
286
-
select {
287
-
case <-ctx.Done():
288
-
return ctx.Err()
289
-
default:
290
-
}
291
-
292
-
if waitErr != nil {
293
-
return waitErr
294
-
}
295
-
296
-
err = e.DestroyStep(ctx, resp.ID)
297
-
if err != nil {
298
-
return err
299
-
}
300
-
301
-
if state.ExitCode != 0 {
302
-
e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled)
303
-
if state.OOMKilled {
304
-
return ErrOOMKilled
305
-
}
306
-
return ErrWorkflowFailed
110
+
return nil
111
+
})
307
112
}
308
113
}
309
114
310
-
return nil
311
-
}
312
-
313
-
func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) {
314
-
wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
315
-
select {
316
-
case err := <-errCh:
317
-
if err != nil {
318
-
return nil, err
319
-
}
320
-
case <-wait:
321
-
}
322
-
323
-
e.l.Info("waited for container", "name", containerID)
324
-
325
-
info, err := e.docker.ContainerInspect(ctx, containerID)
326
-
if err != nil {
327
-
return nil, err
328
-
}
329
-
330
-
return info.State, nil
331
-
}
332
-
333
-
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
334
-
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
335
-
if err != nil {
336
-
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
337
-
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")
338
119
}
339
-
defer wfLogger.Close()
340
-
341
-
ctl := wfLogger.ControlWriter(stepIdx, step)
342
-
ctl.Write([]byte(step.Name))
343
-
344
-
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
345
-
Follow: true,
346
-
ShowStdout: true,
347
-
ShowStderr: true,
348
-
Details: false,
349
-
Timestamps: false,
350
-
})
351
-
if err != nil {
352
-
return err
353
-
}
354
-
355
-
_, err = stdcopy.StdCopy(
356
-
wfLogger.DataWriter("stdout"),
357
-
wfLogger.DataWriter("stderr"),
358
-
logs,
359
-
)
360
-
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
361
-
return fmt.Errorf("failed to copy logs: %w", err)
362
-
}
363
-
364
-
return nil
365
-
}
366
-
367
-
func (e *Engine) DestroyStep(ctx context.Context, containerID string) error {
368
-
err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL
369
-
if err != nil && !isErrContainerNotFoundOrNotRunning(err) {
370
-
return err
371
-
}
372
-
373
-
if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{
374
-
RemoveVolumes: true,
375
-
RemoveLinks: false,
376
-
Force: false,
377
-
}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {
378
-
return err
379
-
}
380
-
381
-
return nil
382
-
}
383
-
384
-
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
385
-
e.cleanupMu.Lock()
386
-
key := wid.String()
387
-
388
-
fns := e.cleanup[key]
389
-
delete(e.cleanup, key)
390
-
e.cleanupMu.Unlock()
391
-
392
-
for _, fn := range fns {
393
-
if err := fn(ctx); err != nil {
394
-
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
395
-
}
396
-
}
397
-
return nil
398
-
}
399
-
400
-
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
401
-
e.cleanupMu.Lock()
402
-
defer e.cleanupMu.Unlock()
403
-
404
-
key := wid.String()
405
-
e.cleanup[key] = append(e.cleanup[key], fn)
406
-
}
407
-
408
-
func workspaceVolume(wid models.WorkflowId) string {
409
-
return fmt.Sprintf("workspace-%s", wid)
410
-
}
411
-
412
-
func nixVolume(wid models.WorkflowId) string {
413
-
return fmt.Sprintf("nix-%s", wid)
414
-
}
415
-
416
-
func networkName(wid models.WorkflowId) string {
417
-
return fmt.Sprintf("workflow-network-%s", wid)
418
-
}
419
-
420
-
func hostConfig(wid models.WorkflowId) *container.HostConfig {
421
-
hostConfig := &container.HostConfig{
422
-
Mounts: []mount.Mount{
423
-
{
424
-
Type: mount.TypeVolume,
425
-
Source: workspaceVolume(wid),
426
-
Target: workspaceDir,
427
-
},
428
-
{
429
-
Type: mount.TypeVolume,
430
-
Source: nixVolume(wid),
431
-
Target: "/nix",
432
-
},
433
-
{
434
-
Type: mount.TypeTmpfs,
435
-
Target: "/tmp",
436
-
ReadOnly: false,
437
-
TmpfsOptions: &mount.TmpfsOptions{
438
-
Mode: 0o1777, // world-writeable sticky bit
439
-
Options: [][]string{
440
-
{"exec"},
441
-
},
442
-
},
443
-
},
444
-
{
445
-
Type: mount.TypeVolume,
446
-
Source: "etc-nix-" + wid.String(),
447
-
Target: "/etc/nix",
448
-
},
449
-
},
450
-
ReadonlyRootfs: false,
451
-
CapDrop: []string{"ALL"},
452
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
453
-
SecurityOpt: []string{"no-new-privileges"},
454
-
ExtraHosts: []string{"host.docker.internal:host-gateway"},
455
-
}
456
-
457
-
return hostConfig
458
-
}
459
-
460
-
// thanks woodpecker
461
-
func isErrContainerNotFoundOrNotRunning(err error) bool {
462
-
// Error response from daemon: Cannot kill container: ...: No such container: ...
463
-
// Error response from daemon: Cannot kill container: ...: Container ... is not running"
464
-
// Error response from podman daemon: can only kill running containers. ... is in state exited
465
-
// Error: No such container: ...
466
-
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"))
467
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.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
-
}
-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
+
}
+421
spindle/engines/nixery/engine.go
+421
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
+
Labels: map[string]string{
205
+
"sh.tangled.pipeline/workflow_id": wid.String(),
206
+
},
207
+
// TODO(winter): investigate whether environment variables passed here
208
+
// get propagated to ContainerExec processes
209
+
}, &container.HostConfig{
210
+
Mounts: []mount.Mount{
211
+
{
212
+
Type: mount.TypeTmpfs,
213
+
Target: "/tmp",
214
+
ReadOnly: false,
215
+
TmpfsOptions: &mount.TmpfsOptions{
216
+
Mode: 0o1777, // world-writeable sticky bit
217
+
Options: [][]string{
218
+
{"exec"},
219
+
},
220
+
},
221
+
},
222
+
},
223
+
ReadonlyRootfs: false,
224
+
CapDrop: []string{"ALL"},
225
+
CapAdd: []string{"CAP_DAC_OVERRIDE"},
226
+
SecurityOpt: []string{"no-new-privileges"},
227
+
ExtraHosts: []string{"host.docker.internal:host-gateway"},
228
+
}, nil, nil, "")
229
+
if err != nil {
230
+
return fmt.Errorf("creating container: %w", err)
231
+
}
232
+
e.registerCleanup(wid, func(ctx context.Context) error {
233
+
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
234
+
if err != nil {
235
+
return err
236
+
}
237
+
238
+
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
239
+
RemoveVolumes: true,
240
+
RemoveLinks: false,
241
+
Force: false,
242
+
})
243
+
})
244
+
245
+
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
246
+
if err != nil {
247
+
return fmt.Errorf("starting container: %w", err)
248
+
}
249
+
250
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{
251
+
Cmd: []string{"mkdir", "-p", workspaceDir, homeDir},
252
+
AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe??
253
+
AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default")
254
+
})
255
+
if err != nil {
256
+
return err
257
+
}
258
+
259
+
// This actually *starts* the command. Thanks, Docker!
260
+
execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{})
261
+
if err != nil {
262
+
return err
263
+
}
264
+
defer execResp.Close()
265
+
266
+
// This is apparently best way to wait for the command to complete.
267
+
_, err = io.ReadAll(execResp.Reader)
268
+
if err != nil {
269
+
return err
270
+
}
271
+
272
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
273
+
if err != nil {
274
+
return err
275
+
}
276
+
277
+
if execInspectResp.ExitCode != 0 {
278
+
return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode)
279
+
} else if execInspectResp.Running {
280
+
return errors.New("mkdir is somehow still running??")
281
+
}
282
+
283
+
addl.container = resp.ID
284
+
wf.Data = addl
285
+
286
+
return nil
287
+
}
288
+
289
+
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
290
+
addl := w.Data.(addlFields)
291
+
workflowEnvs := ConstructEnvs(addl.env)
292
+
// TODO(winter): should SetupWorkflow also have secret access?
293
+
// IMO yes, but probably worth thinking on.
294
+
for _, s := range secrets {
295
+
workflowEnvs.AddEnv(s.Key, s.Value)
296
+
}
297
+
298
+
step := w.Steps[idx].(Step)
299
+
300
+
select {
301
+
case <-ctx.Done():
302
+
return ctx.Err()
303
+
default:
304
+
}
305
+
306
+
envs := append(EnvVars(nil), workflowEnvs...)
307
+
for k, v := range step.environment {
308
+
envs.AddEnv(k, v)
309
+
}
310
+
envs.AddEnv("HOME", homeDir)
311
+
312
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
313
+
Cmd: []string{"bash", "-c", step.command},
314
+
AttachStdout: true,
315
+
AttachStderr: true,
316
+
Env: envs,
317
+
})
318
+
if err != nil {
319
+
return fmt.Errorf("creating exec: %w", err)
320
+
}
321
+
322
+
// start tailing logs in background
323
+
tailDone := make(chan error, 1)
324
+
go func() {
325
+
tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step)
326
+
}()
327
+
328
+
select {
329
+
case <-tailDone:
330
+
331
+
case <-ctx.Done():
332
+
// cleanup will be handled by DestroyWorkflow, since
333
+
// Docker doesn't provide an API to kill an exec run
334
+
// (sure, we could grab the PID and kill it ourselves,
335
+
// but that's wasted effort)
336
+
e.l.Warn("step timed out", "step", step.Name)
337
+
338
+
<-tailDone
339
+
340
+
return engine.ErrTimedOut
341
+
}
342
+
343
+
select {
344
+
case <-ctx.Done():
345
+
return ctx.Err()
346
+
default:
347
+
}
348
+
349
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
350
+
if err != nil {
351
+
return err
352
+
}
353
+
354
+
if execInspectResp.ExitCode != 0 {
355
+
inspectResp, err := e.docker.ContainerInspect(ctx, addl.container)
356
+
if err != nil {
357
+
return err
358
+
}
359
+
360
+
e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled)
361
+
362
+
if inspectResp.State.OOMKilled {
363
+
return ErrOOMKilled
364
+
}
365
+
return engine.ErrWorkflowFailed
366
+
}
367
+
368
+
return nil
369
+
}
370
+
371
+
func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
372
+
if wfLogger == nil {
373
+
return nil
374
+
}
375
+
376
+
// This actually *starts* the command. Thanks, Docker!
377
+
logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
378
+
if err != nil {
379
+
return err
380
+
}
381
+
defer logs.Close()
382
+
383
+
_, err = stdcopy.StdCopy(
384
+
wfLogger.DataWriter("stdout"),
385
+
wfLogger.DataWriter("stderr"),
386
+
logs.Reader,
387
+
)
388
+
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
389
+
return fmt.Errorf("failed to copy logs: %w", err)
390
+
}
391
+
392
+
return nil
393
+
}
394
+
395
+
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
396
+
e.cleanupMu.Lock()
397
+
key := wid.String()
398
+
399
+
fns := e.cleanup[key]
400
+
delete(e.cleanup, key)
401
+
e.cleanupMu.Unlock()
402
+
403
+
for _, fn := range fns {
404
+
if err := fn(ctx); err != nil {
405
+
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
406
+
}
407
+
}
408
+
return nil
409
+
}
410
+
411
+
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
412
+
e.cleanupMu.Lock()
413
+
defer e.cleanupMu.Unlock()
414
+
415
+
key := wid.String()
416
+
e.cleanup[key] = append(e.cleanup[key], fn)
417
+
}
418
+
419
+
func networkName(wid models.WorkflowId) string {
420
+
return fmt.Sprintf("workflow-network-%s", wid)
421
+
}
+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
+
}
+8
-4
spindle/ingester.go
+8
-4
spindle/ingester.go
···
40
40
41
41
switch e.Commit.Collection {
42
42
case tangled.SpindleMemberNSID:
43
-
s.ingestMember(ctx, e)
43
+
err = s.ingestMember(ctx, e)
44
44
case tangled.RepoNSID:
45
-
s.ingestRepo(ctx, e)
45
+
err = s.ingestRepo(ctx, e)
46
46
case tangled.RepoCollaboratorNSID:
47
-
s.ingestCollaborator(ctx, e)
47
+
err = s.ingestCollaborator(ctx, e)
48
48
}
49
49
50
-
return err
50
+
if err != nil {
51
+
s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err)
52
+
}
53
+
54
+
return nil
51
55
}
52
56
}
53
57
+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
}
+8
-103
spindle/models/pipeline.go
+8
-103
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
4
RepoOwner string
12
5
RepoName string
13
-
Workflows []Workflow
6
+
Workflows map[Engine][]Workflow
14
7
}
15
8
16
-
type Step struct {
17
-
Command string
18
-
Name string
19
-
Environment map[string]string
20
-
Kind StepKind
9
+
type Step interface {
10
+
Name() string
11
+
Command() string
12
+
Kind() StepKind
21
13
}
22
14
23
15
type StepKind int
···
30
22
)
31
23
32
24
type Workflow struct {
33
-
Steps []Step
34
-
Environment map[string]string
35
-
Name string
36
-
Image string
37
-
}
38
-
39
-
// setupSteps get added to start of Steps
40
-
type setupSteps []Step
41
-
42
-
// addStep adds a step to the beginning of the workflow's steps.
43
-
func (ss *setupSteps) addStep(step Step) {
44
-
*ss = append(*ss, step)
45
-
}
46
-
47
-
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
48
-
// In the process, dependencies are resolved: nixpkgs deps
49
-
// are constructed atop nixery and set as the Workflow.Image,
50
-
// and ones from custom registries
51
-
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
52
-
workflows := []Workflow{}
53
-
54
-
for _, twf := range pl.Workflows {
55
-
swf := &Workflow{}
56
-
for _, tstep := range twf.Steps {
57
-
sstep := Step{}
58
-
sstep.Environment = stepEnvToMap(tstep.Environment)
59
-
sstep.Command = tstep.Command
60
-
sstep.Name = tstep.Name
61
-
sstep.Kind = StepKindUser
62
-
swf.Steps = append(swf.Steps, sstep)
63
-
}
64
-
swf.Name = twf.Name
65
-
swf.Environment = workflowEnvToMap(twf.Environment)
66
-
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
67
-
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
-
repoOwner := pl.TriggerMetadata.Repo.Did
83
-
repoName := pl.TriggerMetadata.Repo.Repo
84
-
return &Pipeline{
85
-
RepoOwner: repoOwner,
86
-
RepoName: repoName,
87
-
Workflows: workflows,
88
-
}
89
-
}
90
-
91
-
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
92
-
envMap := map[string]string{}
93
-
for _, env := range envs {
94
-
if env != nil {
95
-
envMap[env.Key] = env.Value
96
-
}
97
-
}
98
-
return envMap
99
-
}
100
-
101
-
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
102
-
envMap := map[string]string{}
103
-
for _, env := range envs {
104
-
if env != nil {
105
-
envMap[env.Key] = env.Value
106
-
}
107
-
}
108
-
return envMap
109
-
}
110
-
111
-
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
112
-
var dependencies string
113
-
for _, d := range deps {
114
-
if d.Registry == "nixpkgs" {
115
-
dependencies = path.Join(d.Packages...)
116
-
}
117
-
}
118
-
119
-
// load defaults from somewhere else
120
-
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
121
-
122
-
return path.Join(nixery, dependencies)
25
+
Steps []Step
26
+
Name string
27
+
Data any
123
28
}
-128
spindle/models/setup_steps.go
-128
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
-
if len(packages) == 0 {
106
-
customPackages = append(customPackages, registry)
107
-
}
108
-
// collect packages from custom registries
109
-
for _, pkg := range packages {
110
-
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
111
-
}
112
-
}
113
-
114
-
if len(customPackages) > 0 {
115
-
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
116
-
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
117
-
installStep := Step{
118
-
Command: cmd,
119
-
Name: "Install custom dependencies",
120
-
Environment: map[string]string{
121
-
"NIX_NO_COLOR": "1",
122
-
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
123
-
},
124
-
}
125
-
return &installStep
126
-
}
127
-
return nil
128
-
}
+1
-1
spindle/secrets/sqlite.go
+1
-1
spindle/secrets/sqlite.go
···
24
24
}
25
25
26
26
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
27
-
db, err := sql.Open("sqlite3", dbPath)
27
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
28
28
if err != nil {
29
29
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
30
30
}
+54
-15
spindle/server.go
+54
-15
spindle/server.go
···
20
20
"tangled.sh/tangled.sh/core/spindle/config"
21
21
"tangled.sh/tangled.sh/core/spindle/db"
22
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
+
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
23
24
"tangled.sh/tangled.sh/core/spindle/models"
24
25
"tangled.sh/tangled.sh/core/spindle/queue"
25
26
"tangled.sh/tangled.sh/core/spindle/secrets"
26
27
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
27
29
)
28
30
29
31
//go:embed motd
···
39
41
e *rbac.Enforcer
40
42
l *slog.Logger
41
43
n *notifier.Notifier
42
-
eng *engine.Engine
44
+
engs map[string]models.Engine
43
45
jq *queue.Queue
44
46
cfg *config.Config
45
47
ks *eventconsumer.Consumer
···
93
95
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
94
96
}
95
97
96
-
eng, err := engine.New(ctx, cfg, d, &n, vault)
98
+
nixeryEng, err := nixery.New(ctx, cfg)
97
99
if err != nil {
98
100
return err
99
101
}
100
102
101
-
jq := queue.NewQueue(100, 5)
103
+
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
104
+
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
102
105
103
106
collections := []string{
104
107
tangled.SpindleMemberNSID,
···
128
131
db: d,
129
132
l: logger,
130
133
n: &n,
131
-
eng: eng,
134
+
engs: map[string]models.Engine{"nixery": nixeryEng},
132
135
jq: jq,
133
136
cfg: cfg,
134
137
res: resolver,
···
212
215
func (s *Spindle) XrpcRouter() http.Handler {
213
216
logger := s.l.With("route", "xrpc")
214
217
218
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
219
+
215
220
x := xrpc.Xrpc{
216
-
Logger: logger,
217
-
Db: s.db,
218
-
Enforcer: s.e,
219
-
Engine: s.eng,
220
-
Config: s.cfg,
221
-
Resolver: s.res,
222
-
Vault: s.vault,
221
+
Logger: logger,
222
+
Db: s.db,
223
+
Enforcer: s.e,
224
+
Engines: s.engs,
225
+
Config: s.cfg,
226
+
Resolver: s.res,
227
+
Vault: s.vault,
228
+
ServiceAuth: serviceAuth,
223
229
}
224
230
225
231
return x.Router()
···
242
248
return fmt.Errorf("no repo data found")
243
249
}
244
250
251
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
252
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
253
+
}
254
+
245
255
// filter by repos
246
256
_, err = s.db.GetRepo(
247
257
tpl.TriggerMetadata.Repo.Knot,
···
257
267
Rkey: msg.Rkey,
258
268
}
259
269
270
+
workflows := make(map[models.Engine][]models.Workflow)
271
+
260
272
for _, w := range tpl.Workflows {
261
273
if w != nil {
262
-
err := s.db.StatusPending(models.WorkflowId{
274
+
if _, ok := s.engs[w.Engine]; !ok {
275
+
err = s.db.StatusFailed(models.WorkflowId{
276
+
PipelineId: pipelineId,
277
+
Name: w.Name,
278
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
279
+
if err != nil {
280
+
return err
281
+
}
282
+
283
+
continue
284
+
}
285
+
286
+
eng := s.engs[w.Engine]
287
+
288
+
if _, ok := workflows[eng]; !ok {
289
+
workflows[eng] = []models.Workflow{}
290
+
}
291
+
292
+
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
293
+
if err != nil {
294
+
return err
295
+
}
296
+
297
+
workflows[eng] = append(workflows[eng], *ewf)
298
+
299
+
err = s.db.StatusPending(models.WorkflowId{
263
300
PipelineId: pipelineId,
264
301
Name: w.Name,
265
302
}, s.n)
···
269
306
}
270
307
}
271
308
272
-
spl := models.ToPipeline(tpl, *s.cfg)
273
-
274
309
ok := s.jq.Enqueue(queue.Job{
275
310
Run: func() error {
276
-
s.eng.StartWorkflows(ctx, spl, pipelineId)
311
+
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
312
+
RepoOwner: tpl.TriggerMetadata.Repo.Did,
313
+
RepoName: tpl.TriggerMetadata.Repo.Repo,
314
+
Workflows: workflows,
315
+
}, pipelineId)
277
316
return nil
278
317
},
279
318
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,
+11
-10
spindle/xrpc/add_secret.go
+11
-10
spindle/xrpc/add_secret.go
···
13
13
"tangled.sh/tangled.sh/core/api/tangled"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
15
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
17
)
17
18
18
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
19
20
l := x.Logger
20
-
fail := func(e XrpcError) {
21
+
fail := func(e xrpcerr.XrpcError) {
21
22
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
23
writeError(w, e, http.StatusBadRequest)
23
24
}
24
25
25
26
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
27
if !ok {
27
-
fail(MissingActorDidError)
28
+
fail(xrpcerr.MissingActorDidError)
28
29
return
29
30
}
30
31
31
32
var data tangled.RepoAddSecret_Input
32
33
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
-
fail(GenericError(err))
34
+
fail(xrpcerr.GenericError(err))
34
35
return
35
36
}
36
37
37
38
if err := secrets.ValidateKey(data.Key); err != nil {
38
-
fail(GenericError(err))
39
+
fail(xrpcerr.GenericError(err))
39
40
return
40
41
}
41
42
42
43
// unfortunately we have to resolve repo-at here
43
44
repoAt, err := syntax.ParseATURI(data.Repo)
44
45
if err != nil {
45
-
fail(InvalidRepoError(data.Repo))
46
+
fail(xrpcerr.InvalidRepoError(data.Repo))
46
47
return
47
48
}
48
49
49
50
// resolve this aturi to extract the repo record
50
51
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
51
52
if err != nil || ident.Handle.IsInvalidHandle() {
52
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
53
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
53
54
return
54
55
}
55
56
56
57
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
57
58
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
58
59
if err != nil {
59
-
fail(GenericError(err))
60
+
fail(xrpcerr.GenericError(err))
60
61
return
61
62
}
62
63
63
64
repo := resp.Value.Val.(*tangled.Repo)
64
65
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
65
66
if err != nil {
66
-
fail(GenericError(err))
67
+
fail(xrpcerr.GenericError(err))
67
68
return
68
69
}
69
70
70
71
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
71
72
l.Error("insufficent permissions", "did", actorDid.String())
72
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
73
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
73
74
return
74
75
}
75
76
···
83
84
err = x.Vault.AddSecret(r.Context(), secret)
84
85
if err != nil {
85
86
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
86
-
writeError(w, GenericError(err), http.StatusInternalServerError)
87
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
87
88
return
88
89
}
89
90
+10
-9
spindle/xrpc/list_secrets.go
+10
-9
spindle/xrpc/list_secrets.go
···
13
13
"tangled.sh/tangled.sh/core/api/tangled"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
15
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
17
)
17
18
18
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
19
20
l := x.Logger
20
-
fail := func(e XrpcError) {
21
+
fail := func(e xrpcerr.XrpcError) {
21
22
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
23
writeError(w, e, http.StatusBadRequest)
23
24
}
24
25
25
26
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
27
if !ok {
27
-
fail(MissingActorDidError)
28
+
fail(xrpcerr.MissingActorDidError)
28
29
return
29
30
}
30
31
31
32
repoParam := r.URL.Query().Get("repo")
32
33
if repoParam == "" {
33
-
fail(GenericError(fmt.Errorf("empty params")))
34
+
fail(xrpcerr.GenericError(fmt.Errorf("empty params")))
34
35
return
35
36
}
36
37
37
38
// unfortunately we have to resolve repo-at here
38
39
repoAt, err := syntax.ParseATURI(repoParam)
39
40
if err != nil {
40
-
fail(InvalidRepoError(repoParam))
41
+
fail(xrpcerr.InvalidRepoError(repoParam))
41
42
return
42
43
}
43
44
44
45
// resolve this aturi to extract the repo record
45
46
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
46
47
if err != nil || ident.Handle.IsInvalidHandle() {
47
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
49
return
49
50
}
50
51
51
52
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
52
53
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
53
54
if err != nil {
54
-
fail(GenericError(err))
55
+
fail(xrpcerr.GenericError(err))
55
56
return
56
57
}
57
58
58
59
repo := resp.Value.Val.(*tangled.Repo)
59
60
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
61
if err != nil {
61
-
fail(GenericError(err))
62
+
fail(xrpcerr.GenericError(err))
62
63
return
63
64
}
64
65
65
66
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
67
l.Error("insufficent permissions", "did", actorDid.String())
67
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
69
return
69
70
}
70
71
71
72
ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath))
72
73
if err != nil {
73
74
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
74
-
writeError(w, GenericError(err), http.StatusInternalServerError)
75
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
75
76
return
76
77
}
77
78
+10
-9
spindle/xrpc/remove_secret.go
+10
-9
spindle/xrpc/remove_secret.go
···
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
13
"tangled.sh/tangled.sh/core/rbac"
14
14
"tangled.sh/tangled.sh/core/spindle/secrets"
15
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
16
)
16
17
17
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
18
19
l := x.Logger
19
-
fail := func(e XrpcError) {
20
+
fail := func(e xrpcerr.XrpcError) {
20
21
l.Error("failed", "kind", e.Tag, "error", e.Message)
21
22
writeError(w, e, http.StatusBadRequest)
22
23
}
23
24
24
25
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
25
26
if !ok {
26
-
fail(MissingActorDidError)
27
+
fail(xrpcerr.MissingActorDidError)
27
28
return
28
29
}
29
30
30
31
var data tangled.RepoRemoveSecret_Input
31
32
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
-
fail(GenericError(err))
33
+
fail(xrpcerr.GenericError(err))
33
34
return
34
35
}
35
36
36
37
// unfortunately we have to resolve repo-at here
37
38
repoAt, err := syntax.ParseATURI(data.Repo)
38
39
if err != nil {
39
-
fail(InvalidRepoError(data.Repo))
40
+
fail(xrpcerr.InvalidRepoError(data.Repo))
40
41
return
41
42
}
42
43
43
44
// resolve this aturi to extract the repo record
44
45
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
45
46
if err != nil || ident.Handle.IsInvalidHandle() {
46
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
48
return
48
49
}
49
50
50
51
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
51
52
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
52
53
if err != nil {
53
-
fail(GenericError(err))
54
+
fail(xrpcerr.GenericError(err))
54
55
return
55
56
}
56
57
57
58
repo := resp.Value.Val.(*tangled.Repo)
58
59
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
59
60
if err != nil {
60
-
fail(GenericError(err))
61
+
fail(xrpcerr.GenericError(err))
61
62
return
62
63
}
63
64
64
65
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
65
66
l.Error("insufficent permissions", "did", actorDid.String())
66
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
68
return
68
69
}
69
70
···
74
75
err = x.Vault.RemoveSecret(r.Context(), secret)
75
76
if err != nil {
76
77
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
77
-
writeError(w, GenericError(err), http.StatusInternalServerError)
78
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
78
79
return
79
80
}
80
81
+15
-110
spindle/xrpc/xrpc.go
+15
-110
spindle/xrpc/xrpc.go
···
1
1
package xrpc
2
2
3
3
import (
4
-
"context"
5
4
_ "embed"
6
5
"encoding/json"
7
-
"fmt"
8
6
"log/slog"
9
7
"net/http"
10
-
"strings"
11
8
12
-
"github.com/bluesky-social/indigo/atproto/auth"
13
9
"github.com/go-chi/chi/v5"
14
10
15
11
"tangled.sh/tangled.sh/core/api/tangled"
···
17
13
"tangled.sh/tangled.sh/core/rbac"
18
14
"tangled.sh/tangled.sh/core/spindle/config"
19
15
"tangled.sh/tangled.sh/core/spindle/db"
20
-
"tangled.sh/tangled.sh/core/spindle/engine"
16
+
"tangled.sh/tangled.sh/core/spindle/models"
21
17
"tangled.sh/tangled.sh/core/spindle/secrets"
18
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
22
20
)
23
21
24
22
const ActorDid string = "ActorDid"
25
23
26
24
type Xrpc struct {
27
-
Logger *slog.Logger
28
-
Db *db.DB
29
-
Enforcer *rbac.Enforcer
30
-
Engine *engine.Engine
31
-
Config *config.Config
32
-
Resolver *idresolver.Resolver
33
-
Vault secrets.Manager
25
+
Logger *slog.Logger
26
+
Db *db.DB
27
+
Enforcer *rbac.Enforcer
28
+
Engines map[string]models.Engine
29
+
Config *config.Config
30
+
Resolver *idresolver.Resolver
31
+
Vault secrets.Manager
32
+
ServiceAuth *serviceauth.ServiceAuth
34
33
}
35
34
36
35
func (x *Xrpc) Router() http.Handler {
37
36
r := chi.NewRouter()
38
37
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)
38
+
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
39
+
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
40
+
r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
42
41
43
42
return r
44
43
}
45
44
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
45
// this is slightly different from http_util::write_error to follow the spec:
141
46
//
142
47
// the json object returned must include an "error" and a "message"
143
-
func writeError(w http.ResponseWriter, e XrpcError, status int) {
48
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
144
49
w.Header().Set("Content-Type", "application/json")
145
50
w.WriteHeader(status)
146
51
json.NewEncoder(w).Encode(e)
+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": {},
+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
-
}
+110
xrpc/errors/errors.go
+110
xrpc/errors/errors.go
···
1
+
package errors
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
)
7
+
8
+
type XrpcError struct {
9
+
Tag string `json:"error"`
10
+
Message string `json:"message"`
11
+
}
12
+
13
+
func (x XrpcError) Error() string {
14
+
if x.Message != "" {
15
+
return fmt.Sprintf("%s: %s", x.Tag, x.Message)
16
+
}
17
+
return x.Tag
18
+
}
19
+
20
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
21
+
x := XrpcError{}
22
+
for _, o := range opts {
23
+
o(&x)
24
+
}
25
+
26
+
return x
27
+
}
28
+
29
+
type ErrOpt = func(xerr *XrpcError)
30
+
31
+
func WithTag(tag string) ErrOpt {
32
+
return func(xerr *XrpcError) {
33
+
xerr.Tag = tag
34
+
}
35
+
}
36
+
37
+
func WithMessage[S ~string](s S) ErrOpt {
38
+
return func(xerr *XrpcError) {
39
+
xerr.Message = string(s)
40
+
}
41
+
}
42
+
43
+
func WithError(e error) ErrOpt {
44
+
return func(xerr *XrpcError) {
45
+
xerr.Message = e.Error()
46
+
}
47
+
}
48
+
49
+
var MissingActorDidError = NewXrpcError(
50
+
WithTag("MissingActorDid"),
51
+
WithMessage("actor DID not supplied"),
52
+
)
53
+
54
+
var AuthError = func(err error) XrpcError {
55
+
return NewXrpcError(
56
+
WithTag("Auth"),
57
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
58
+
)
59
+
}
60
+
61
+
var InvalidRepoError = func(r string) XrpcError {
62
+
return NewXrpcError(
63
+
WithTag("InvalidRepo"),
64
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
65
+
)
66
+
}
67
+
68
+
var GitError = func(e error) XrpcError {
69
+
return NewXrpcError(
70
+
WithTag("Git"),
71
+
WithError(fmt.Errorf("git error: %w", e)),
72
+
)
73
+
}
74
+
75
+
var AccessControlError = func(d string) XrpcError {
76
+
return NewXrpcError(
77
+
WithTag("AccessControl"),
78
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
79
+
)
80
+
}
81
+
82
+
var RepoExistsError = func(r string) XrpcError {
83
+
return NewXrpcError(
84
+
WithTag("RepoExists"),
85
+
WithError(fmt.Errorf("repo already exists: %s", r)),
86
+
)
87
+
}
88
+
89
+
var RecordExistsError = func(r string) XrpcError {
90
+
return NewXrpcError(
91
+
WithTag("RecordExists"),
92
+
WithError(fmt.Errorf("repo already exists: %s", r)),
93
+
)
94
+
}
95
+
96
+
func GenericError(err error) XrpcError {
97
+
return NewXrpcError(
98
+
WithTag("Generic"),
99
+
WithError(err),
100
+
)
101
+
}
102
+
103
+
func Unmarshal(errStr string) (XrpcError, error) {
104
+
var xerr XrpcError
105
+
err := json.Unmarshal([]byte(errStr), &xerr)
106
+
if err != nil {
107
+
return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err)
108
+
}
109
+
return xerr, nil
110
+
}
+65
xrpc/serviceauth/service_auth.go
+65
xrpc/serviceauth/service_auth.go
···
1
+
package serviceauth
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"log/slog"
7
+
"net/http"
8
+
"strings"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/auth"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
)
14
+
15
+
const ActorDid string = "ActorDid"
16
+
17
+
type ServiceAuth struct {
18
+
logger *slog.Logger
19
+
resolver *idresolver.Resolver
20
+
audienceDid string
21
+
}
22
+
23
+
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
24
+
return &ServiceAuth{
25
+
logger: logger,
26
+
resolver: resolver,
27
+
audienceDid: audienceDid,
28
+
}
29
+
}
30
+
31
+
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
32
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
+
l := sa.logger.With("url", r.URL)
34
+
35
+
token := r.Header.Get("Authorization")
36
+
token = strings.TrimPrefix(token, "Bearer ")
37
+
38
+
s := auth.ServiceAuthValidator{
39
+
Audience: sa.audienceDid,
40
+
Dir: sa.resolver.Directory(),
41
+
}
42
+
43
+
did, err := s.Validate(r.Context(), token, nil)
44
+
if err != nil {
45
+
l.Error("signature verification failed", "err", err)
46
+
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
47
+
return
48
+
}
49
+
50
+
r = r.WithContext(
51
+
context.WithValue(r.Context(), ActorDid, did),
52
+
)
53
+
54
+
next.ServeHTTP(w, r)
55
+
})
56
+
}
57
+
58
+
// this is slightly different from http_util::write_error to follow the spec:
59
+
//
60
+
// the json object returned must include an "error" and a "message"
61
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
62
+
w.Header().Set("Content-Type", "application/json")
63
+
w.WriteHeader(status)
64
+
json.NewEncoder(w).Encode(e)
65
+
}