forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+16500 -11027
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+3
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+3 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master", "ci"] 3 + branch: ["master"] 4 + 5 + engine: nixery 4 6 5 7 dependencies: 6 8 nixpkgs:
+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
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master", "ci"] 3 + branch: ["master"] 4 + 5 + engine: nixery 4 6 5 7 dependencies: 6 8 nixpkgs:
-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 - }
+571 -1332
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 { 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4690 4427 return err 4691 4428 } 4692 4429 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 { 4698 - return err 4699 - } 4700 - for _, v := range t.Environment { 4701 - if err := v.MarshalCBOR(cw); err != nil { 4702 - return err 4703 - } 4704 - 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4705 4432 } 4706 4433 4707 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4708 - if len("dependencies") > 1000000 { 4709 - return xerrors.Errorf("Value in field \"dependencies\" was too long") 4710 - } 4711 - 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 - } 4526 + // t.Engine (string) (string) 4527 + case "engine": 4826 4528 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 - 4529 + { 4530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4531 + if err != nil { 4532 + return err 4854 4533 } 4855 - } 4856 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4857 - case "environment": 4858 4534 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 - } 4902 - 4903 - } 4904 - } 4905 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4906 - case "dependencies": 4907 - 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 5901 + fieldCount := 5 6401 5902 6402 - if t.CommentId == nil { 6403 - fieldCount-- 6404 - } 6405 - 6406 - if t.Owner == nil { 6407 - fieldCount-- 6408 - } 6409 - 6410 - if t.Repo == nil { 5903 + if t.ReplyTo == nil { 6411 5904 fieldCount-- 6412 5905 } 6413 5906 ··· 6438 5931 return err 6439 5932 } 6440 5933 6441 - // t.Repo (string) (string) 6442 - if t.Repo != nil { 6443 - 6444 - if len("repo") > 1000000 { 6445 - return xerrors.Errorf("Value in field \"repo\" was too long") 6446 - } 6447 - 6448 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 6449 - return err 6450 - } 6451 - if _, err := cw.WriteString(string("repo")); err != nil { 6452 - return err 6453 - } 6454 - 6455 - if t.Repo == nil { 6456 - if _, err := cw.Write(cbg.CborNull); err != nil { 6457 - return err 6458 - } 6459 - } else { 6460 - if len(*t.Repo) > 1000000 { 6461 - return xerrors.Errorf("Value in field t.Repo was too long") 6462 - } 6463 - 6464 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 6465 - return err 6466 - } 6467 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 6468 - return err 6469 - } 6470 - } 6471 - } 6472 - 6473 5934 // t.LexiconTypeID (string) (string) 6474 5935 if len("$type") > 1000000 { 6475 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6512 5973 return err 6513 5974 } 6514 5975 6515 - // t.Owner (string) (string) 6516 - if t.Owner != nil { 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 6517 5978 6518 - if len("owner") > 1000000 { 6519 - return xerrors.Errorf("Value in field \"owner\" was too long") 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 6520 5981 } 6521 5982 6522 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 6523 5984 return err 6524 5985 } 6525 - if _, err := cw.WriteString(string("owner")); err != nil { 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 6526 5987 return err 6527 5988 } 6528 5989 6529 - if t.Owner == nil { 5990 + if t.ReplyTo == nil { 6530 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 6531 5992 return err 6532 5993 } 6533 5994 } else { 6534 - if len(*t.Owner) > 1000000 { 6535 - return xerrors.Errorf("Value in field t.Owner was too long") 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 6536 5997 } 6537 5998 6538 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6539 6000 return err 6540 6001 } 6541 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6542 6003 return err 6543 6004 } 6544 6005 } 6545 6006 } 6546 6007 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 6008 // t.CreatedAt (string) (string) 6580 6009 if len("createdAt") > 1000000 { 6581 6010 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6653 6082 6654 6083 t.Body = string(sval) 6655 6084 } 6656 - // t.Repo (string) (string) 6657 - case "repo": 6658 - 6659 - { 6660 - b, err := cr.ReadByte() 6661 - if err != nil { 6662 - return err 6663 - } 6664 - if b != cbg.CborNull[0] { 6665 - if err := cr.UnreadByte(); err != nil { 6666 - return err 6667 - } 6668 - 6669 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6670 - if err != nil { 6671 - return err 6672 - } 6673 - 6674 - t.Repo = (*string)(&sval) 6675 - } 6676 - } 6677 6085 // t.LexiconTypeID (string) (string) 6678 6086 case "$type": 6679 6087 ··· 6696 6104 6697 6105 t.Issue = string(sval) 6698 6106 } 6699 - // t.Owner (string) (string) 6700 - case "owner": 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6701 6109 6702 6110 { 6703 6111 b, err := cr.ReadByte() ··· 6714 6122 return err 6715 6123 } 6716 6124 6717 - t.Owner = (*string)(&sval) 6718 - } 6719 - } 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) 6125 + t.ReplyTo = (*string)(&sval) 6754 6126 } 6755 6127 } 6756 6128 // t.CreatedAt (string) (string) ··· 6946 6318 } 6947 6319 6948 6320 cw := cbg.NewCborWriter(w) 6949 - fieldCount := 9 6321 + fieldCount := 7 6950 6322 6951 6323 if t.Body == nil { 6952 6324 fieldCount-- ··· 7057 6429 return err 7058 6430 } 7059 6431 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 6432 // t.Source (tangled.RepoPull_Source) (struct) 7083 6433 if t.Source != nil { 7084 6434 ··· 7098 6448 } 7099 6449 } 7100 6450 7101 - // t.CreatedAt (string) (string) 7102 - if len("createdAt") > 1000000 { 7103 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 6451 + // t.Target (tangled.RepoPull_Target) (struct) 6452 + if len("target") > 1000000 { 6453 + return xerrors.Errorf("Value in field \"target\" was too long") 7104 6454 } 7105 6455 7106 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6456 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil { 7107 6457 return err 7108 6458 } 7109 - if _, err := cw.WriteString(string("createdAt")); err != nil { 6459 + if _, err := cw.WriteString(string("target")); err != nil { 7110 6460 return err 7111 6461 } 7112 6462 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 { 6463 + if err := t.Target.MarshalCBOR(cw); err != nil { 7121 6464 return err 7122 6465 } 7123 6466 7124 - // t.TargetRepo (string) (string) 7125 - if len("targetRepo") > 1000000 { 7126 - return xerrors.Errorf("Value in field \"targetRepo\" was too long") 6467 + // t.CreatedAt (string) (string) 6468 + if len("createdAt") > 1000000 { 6469 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7127 6470 } 7128 6471 7129 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil { 6472 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7130 6473 return err 7131 6474 } 7132 - if _, err := cw.WriteString(string("targetRepo")); err != nil { 6475 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7133 6476 return err 7134 6477 } 7135 6478 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") 6479 + if len(t.CreatedAt) > 1000000 { 6480 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7150 6481 } 7151 6482 7152 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil { 6483 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7153 6484 return err 7154 6485 } 7155 - if _, err := cw.WriteString(string("targetBranch")); err != nil { 7156 - return err 7157 - } 7158 - 7159 - if len(t.TargetBranch) > 1000000 { 7160 - return xerrors.Errorf("Value in field t.TargetBranch was too long") 7161 - } 7162 - 7163 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil { 7164 - return err 7165 - } 7166 - if _, err := cw.WriteString(string(t.TargetBranch)); err != nil { 6486 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7167 6487 return err 7168 6488 } 7169 6489 return nil ··· 7194 6514 7195 6515 n := extra 7196 6516 7197 - nameBuf := make([]byte, 12) 6517 + nameBuf := make([]byte, 9) 7198 6518 for i := uint64(0); i < n; i++ { 7199 6519 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7200 6520 if err != nil { ··· 7264 6584 7265 6585 t.Title = string(sval) 7266 6586 } 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 6587 // t.Source (tangled.RepoPull_Source) (struct) 7294 6588 case "source": 7295 6589 ··· 7310 6604 } 7311 6605 7312 6606 } 7313 - // t.CreatedAt (string) (string) 7314 - case "createdAt": 6607 + // t.Target (tangled.RepoPull_Target) (struct) 6608 + case "target": 7315 6609 7316 6610 { 7317 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7318 - if err != nil { 7319 - return err 7320 - } 7321 6611 7322 - t.CreatedAt = string(sval) 7323 - } 7324 - // t.TargetRepo (string) (string) 7325 - case "targetRepo": 7326 - 7327 - { 7328 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6612 + b, err := cr.ReadByte() 7329 6613 if err != nil { 7330 6614 return err 7331 6615 } 6616 + if b != cbg.CborNull[0] { 6617 + if err := cr.UnreadByte(); err != nil { 6618 + return err 6619 + } 6620 + t.Target = new(RepoPull_Target) 6621 + if err := t.Target.UnmarshalCBOR(cr); err != nil { 6622 + return xerrors.Errorf("unmarshaling t.Target pointer: %w", err) 6623 + } 6624 + } 7332 6625 7333 - t.TargetRepo = string(sval) 7334 6626 } 7335 - // t.TargetBranch (string) (string) 7336 - case "targetBranch": 6627 + // t.CreatedAt (string) (string) 6628 + case "createdAt": 7337 6629 7338 6630 { 7339 6631 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 7341 6633 return err 7342 6634 } 7343 6635 7344 - t.TargetBranch = string(sval) 6636 + t.CreatedAt = string(sval) 7345 6637 } 7346 6638 7347 6639 default: ··· 7361 6653 } 7362 6654 7363 6655 cw := cbg.NewCborWriter(w) 7364 - fieldCount := 7 7365 - 7366 - if t.CommentId == nil { 7367 - fieldCount-- 7368 - } 7369 6656 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 { 6657 + if _, err := cw.Write([]byte{164}); err != nil { 7379 6658 return err 7380 6659 } 7381 6660 ··· 7425 6704 return err 7426 6705 } 7427 6706 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 6707 // t.LexiconTypeID (string) (string) 7461 6708 if len("$type") > 1000000 { 7462 6709 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 7476 6723 return err 7477 6724 } 7478 6725 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 6726 // t.CreatedAt (string) (string) 7544 6727 if len("createdAt") > 1000000 { 7545 6728 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7628 6811 7629 6812 t.Pull = string(sval) 7630 6813 } 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 6814 // t.LexiconTypeID (string) (string) 7653 6815 case "$type": 7654 6816 ··· 7659 6821 } 7660 6822 7661 6823 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 6824 } 7720 6825 // t.CreatedAt (string) (string) 7721 6826 case "createdAt": ··· 8083 7188 } 8084 7189 8085 7190 t.Status = string(sval) 7191 + } 7192 + 7193 + default: 7194 + // Field doesn't exist on this type, so ignore it 7195 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7196 + return err 7197 + } 7198 + } 7199 + } 7200 + 7201 + return nil 7202 + } 7203 + func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error { 7204 + if t == nil { 7205 + _, err := w.Write(cbg.CborNull) 7206 + return err 7207 + } 7208 + 7209 + cw := cbg.NewCborWriter(w) 7210 + 7211 + if _, err := cw.Write([]byte{162}); err != nil { 7212 + return err 7213 + } 7214 + 7215 + // t.Repo (string) (string) 7216 + if len("repo") > 1000000 { 7217 + return xerrors.Errorf("Value in field \"repo\" was too long") 7218 + } 7219 + 7220 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7221 + return err 7222 + } 7223 + if _, err := cw.WriteString(string("repo")); err != nil { 7224 + return err 7225 + } 7226 + 7227 + if len(t.Repo) > 1000000 { 7228 + return xerrors.Errorf("Value in field t.Repo was too long") 7229 + } 7230 + 7231 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 7232 + return err 7233 + } 7234 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 7235 + return err 7236 + } 7237 + 7238 + // t.Branch (string) (string) 7239 + if len("branch") > 1000000 { 7240 + return xerrors.Errorf("Value in field \"branch\" was too long") 7241 + } 7242 + 7243 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil { 7244 + return err 7245 + } 7246 + if _, err := cw.WriteString(string("branch")); err != nil { 7247 + return err 7248 + } 7249 + 7250 + if len(t.Branch) > 1000000 { 7251 + return xerrors.Errorf("Value in field t.Branch was too long") 7252 + } 7253 + 7254 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil { 7255 + return err 7256 + } 7257 + if _, err := cw.WriteString(string(t.Branch)); err != nil { 7258 + return err 7259 + } 7260 + return nil 7261 + } 7262 + 7263 + func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) { 7264 + *t = RepoPull_Target{} 7265 + 7266 + cr := cbg.NewCborReader(r) 7267 + 7268 + maj, extra, err := cr.ReadHeader() 7269 + if err != nil { 7270 + return err 7271 + } 7272 + defer func() { 7273 + if err == io.EOF { 7274 + err = io.ErrUnexpectedEOF 7275 + } 7276 + }() 7277 + 7278 + if maj != cbg.MajMap { 7279 + return fmt.Errorf("cbor input should be of type map") 7280 + } 7281 + 7282 + if extra > cbg.MaxLength { 7283 + return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra) 7284 + } 7285 + 7286 + n := extra 7287 + 7288 + nameBuf := make([]byte, 6) 7289 + for i := uint64(0); i < n; i++ { 7290 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7291 + if err != nil { 7292 + return err 7293 + } 7294 + 7295 + if !ok { 7296 + // Field doesn't exist on this type, so ignore it 7297 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7298 + return err 7299 + } 7300 + continue 7301 + } 7302 + 7303 + switch string(nameBuf[:nameLen]) { 7304 + // t.Repo (string) (string) 7305 + case "repo": 7306 + 7307 + { 7308 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7309 + if err != nil { 7310 + return err 7311 + } 7312 + 7313 + t.Repo = string(sval) 7314 + } 7315 + // t.Branch (string) (string) 7316 + case "branch": 7317 + 7318 + { 7319 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7320 + if err != nil { 7321 + return err 7322 + } 7323 + 7324 + t.Branch = string(sval) 8086 7325 } 8087 7326 8088 7327 default:
+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 -3
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 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 27 25 }
+53
api/tangled/knotlistKeys.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.listKeys 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotListKeysNSID = "sh.tangled.knot.listKeys" 15 + ) 16 + 17 + // KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call. 18 + type KnotListKeys_Output struct { 19 + // cursor: Pagination cursor for next page 20 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 21 + Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"` 22 + } 23 + 24 + // KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema. 25 + type KnotListKeys_PublicKey struct { 26 + // createdAt: Key upload timestamp 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // did: DID associated with the public key 29 + Did string `json:"did" cborgen:"did"` 30 + // key: Public key contents 31 + Key string `json:"key" cborgen:"key"` 32 + } 33 + 34 + // KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys". 35 + // 36 + // cursor: Pagination cursor 37 + // limit: Maximum number of keys to return 38 + func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) { 39 + var out KnotListKeys_Output 40 + 41 + params := map[string]interface{}{} 42 + if cursor != "" { 43 + params["cursor"] = cursor 44 + } 45 + if limit != 0 { 46 + params["limit"] = limit 47 + } 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+30
api/tangled/knotversion.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.version 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotVersionNSID = "sh.tangled.knot.version" 15 + ) 16 + 17 + // KnotVersion_Output is the output of a sh.tangled.knot.version call. 18 + type KnotVersion_Output struct { 19 + Version string `json:"version" cborgen:"version"` 20 + } 21 + 22 + // KnotVersion calls the XRPC method "sh.tangled.knot.version". 23 + func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) { 24 + var out KnotVersion_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+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 }
+41
api/tangled/repoarchive.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.archive 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoArchiveNSID = "sh.tangled.repo.archive" 16 + ) 17 + 18 + // RepoArchive calls the XRPC method "sh.tangled.repo.archive". 19 + // 20 + // format: Archive format 21 + // prefix: Prefix for files in the archive 22 + // ref: Git reference (branch, tag, or commit SHA) 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 + buf := new(bytes.Buffer) 26 + 27 + params := map[string]interface{}{} 28 + if format != "" { 29 + params["format"] = format 30 + } 31 + if prefix != "" { 32 + params["prefix"] = prefix 33 + } 34 + params["ref"] = ref 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil { 37 + return nil, err 38 + } 39 + 40 + return buf.Bytes(), nil 41 + }
+80
api/tangled/repoblob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.blob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBlobNSID = "sh.tangled.repo.blob" 15 + ) 16 + 17 + // RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema. 18 + type RepoBlob_LastCommit struct { 19 + Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Commit hash 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Commit message 23 + Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 + // when: Commit timestamp 27 + When string `json:"when" cborgen:"when"` 28 + } 29 + 30 + // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 + type RepoBlob_Output struct { 32 + // content: File content (base64 encoded for binary files) 33 + Content string `json:"content" cborgen:"content"` 34 + // encoding: Content encoding 35 + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 + // isBinary: Whether the file is binary 37 + IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` 38 + LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 39 + // mimeType: MIME type of the file 40 + MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"` 41 + // path: The file path 42 + Path string `json:"path" cborgen:"path"` 43 + // ref: The git reference used 44 + Ref string `json:"ref" cborgen:"ref"` 45 + // size: File size in bytes 46 + Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + } 48 + 49 + // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. 50 + type RepoBlob_Signature struct { 51 + // email: Author email 52 + Email string `json:"email" cborgen:"email"` 53 + // name: Author name 54 + Name string `json:"name" cborgen:"name"` 55 + // when: Author timestamp 56 + When string `json:"when" cborgen:"when"` 57 + } 58 + 59 + // RepoBlob calls the XRPC method "sh.tangled.repo.blob". 60 + // 61 + // path: Path to the file within the repository 62 + // raw: Return raw file content instead of JSON response 63 + // ref: Git reference (branch, tag, or commit SHA) 64 + // repo: Repository identifier in format 'did:plc:.../repoName' 65 + func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) { 66 + var out RepoBlob_Output 67 + 68 + params := map[string]interface{}{} 69 + params["path"] = path 70 + if raw { 71 + params["raw"] = raw 72 + } 73 + params["ref"] = ref 74 + params["repo"] = repo 75 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+59
api/tangled/repobranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBranchNSID = "sh.tangled.repo.branch" 15 + ) 16 + 17 + // RepoBranch_Output is the output of a sh.tangled.repo.branch call. 18 + type RepoBranch_Output struct { 19 + Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on this branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // isDefault: Whether this is the default branch 23 + IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"` 24 + // message: Latest commit message 25 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 26 + // name: Branch name 27 + Name string `json:"name" cborgen:"name"` 28 + // shortHash: Short commit hash 29 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 30 + // when: Timestamp of latest commit 31 + When string `json:"when" cborgen:"when"` 32 + } 33 + 34 + // RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema. 35 + type RepoBranch_Signature struct { 36 + // email: Author email 37 + Email string `json:"email" cborgen:"email"` 38 + // name: Author name 39 + Name string `json:"name" cborgen:"name"` 40 + // when: Author timestamp 41 + When string `json:"when" cborgen:"when"` 42 + } 43 + 44 + // RepoBranch calls the XRPC method "sh.tangled.repo.branch". 45 + // 46 + // name: Branch name to get information for 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) { 49 + var out RepoBranch_Output 50 + 51 + params := map[string]interface{}{} 52 + params["name"] = name 53 + params["repo"] = repo 54 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+39
api/tangled/repobranches.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branches 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoBranchesNSID = "sh.tangled.repo.branches" 16 + ) 17 + 18 + // RepoBranches calls the XRPC method "sh.tangled.repo.branches". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of branches to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+35
api/tangled/repocompare.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.compare 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoCompareNSID = "sh.tangled.repo.compare" 16 + ) 17 + 18 + // RepoCompare calls the XRPC method "sh.tangled.repo.compare". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // rev1: First revision (commit, branch, or tag) 22 + // rev2: Second revision (commit, branch, or tag) 23 + func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + params["repo"] = repo 28 + params["rev1"] = rev1 29 + params["rev2"] = rev2 30 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil { 31 + return nil, err 32 + } 33 + 34 + return buf.Bytes(), nil 35 + }
+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
··· 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 + }
+33
api/tangled/repodiff.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.diff 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoDiffNSID = "sh.tangled.repo.diff" 16 + ) 17 + 18 + // RepoDiff calls the XRPC method "sh.tangled.repo.diff". 19 + // 20 + // ref: Git reference (branch, tag, or commit SHA) 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["ref"] = ref 27 + params["repo"] = repo 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+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
··· 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 + }
+55
api/tangled/repogetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch" 15 + ) 16 + 17 + // RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call. 18 + type RepoGetDefaultBranch_Output struct { 19 + Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on default branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Latest commit message 23 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 + // name: Default branch name 25 + Name string `json:"name" cborgen:"name"` 26 + // shortHash: Short commit hash 27 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 28 + // when: Timestamp of latest commit 29 + When string `json:"when" cborgen:"when"` 30 + } 31 + 32 + // RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema. 33 + type RepoGetDefaultBranch_Signature struct { 34 + // email: Author email 35 + Email string `json:"email" cborgen:"email"` 36 + // name: Author name 37 + Name string `json:"name" cborgen:"name"` 38 + // when: Author timestamp 39 + When string `json:"when" cborgen:"when"` 40 + } 41 + 42 + // RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch". 43 + // 44 + // repo: Repository identifier in format 'did:plc:.../repoName' 45 + func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) { 46 + var out RepoGetDefaultBranch_Output 47 + 48 + params := map[string]interface{}{} 49 + params["repo"] = repo 50 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil { 51 + return nil, err 52 + } 53 + 54 + return &out, nil 55 + }
+45
api/tangled/repohiddenRef.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
-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 }
+61
api/tangled/repolanguages.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.languages 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoLanguagesNSID = "sh.tangled.repo.languages" 15 + ) 16 + 17 + // RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema. 18 + type RepoLanguages_Language struct { 19 + // color: Hex color code for this language 20 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 + // extensions: File extensions associated with this language 22 + Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 + // fileCount: Number of files in this language 24 + FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 + // name: Programming language name 26 + Name string `json:"name" cborgen:"name"` 27 + // percentage: Percentage of total codebase (0-100) 28 + Percentage int64 `json:"percentage" cborgen:"percentage"` 29 + // size: Total size of files in this language (bytes) 30 + Size int64 `json:"size" cborgen:"size"` 31 + } 32 + 33 + // RepoLanguages_Output is the output of a sh.tangled.repo.languages call. 34 + type RepoLanguages_Output struct { 35 + Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + // totalFiles: Total number of files analyzed 39 + TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 + // totalSize: Total size of all analyzed files in bytes 41 + TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 + } 43 + 44 + // RepoLanguages calls the XRPC method "sh.tangled.repo.languages". 45 + // 46 + // ref: Git reference (branch, tag, or commit SHA) 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) { 49 + var out RepoLanguages_Output 50 + 51 + params := map[string]interface{}{} 52 + if ref != "" { 53 + params["ref"] = ref 54 + } 55 + params["repo"] = repo 56 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil { 57 + return nil, err 58 + } 59 + 60 + return &out, nil 61 + }
+45
api/tangled/repolog.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.log 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoLogNSID = "sh.tangled.repo.log" 16 + ) 17 + 18 + // RepoLog calls the XRPC method "sh.tangled.repo.log". 19 + // 20 + // cursor: Pagination cursor (commit SHA) 21 + // limit: Maximum number of commits to return 22 + // path: Path to filter commits by 23 + // ref: Git reference (branch, tag, or commit SHA) 24 + // repo: Repository identifier in format 'did:plc:.../repoName' 25 + func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) { 26 + buf := new(bytes.Buffer) 27 + 28 + params := map[string]interface{}{} 29 + if cursor != "" { 30 + params["cursor"] = cursor 31 + } 32 + if limit != 0 { 33 + params["limit"] = limit 34 + } 35 + if path != "" { 36 + params["path"] = path 37 + } 38 + params["ref"] = ref 39 + params["repo"] = repo 40 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil { 41 + return nil, err 42 + } 43 + 44 + return buf.Bytes(), nil 45 + }
+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
··· 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
··· 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 + }
+39
api/tangled/repotags.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+72
api/tangled/repotree.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tree 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoTreeNSID = "sh.tangled.repo.tree" 15 + ) 16 + 17 + // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 + type RepoTree_LastCommit struct { 19 + // hash: Commit hash 20 + Hash string `json:"hash" cborgen:"hash"` 21 + // message: Commit message 22 + Message string `json:"message" cborgen:"message"` 23 + // when: Commit timestamp 24 + When string `json:"when" cborgen:"when"` 25 + } 26 + 27 + // RepoTree_Output is the output of a sh.tangled.repo.tree call. 28 + type RepoTree_Output struct { 29 + // dotdot: Parent directory path 30 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 + // parent: The parent path in the tree 33 + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // ref: The git reference used 35 + Ref string `json:"ref" cborgen:"ref"` 36 + } 37 + 38 + // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 39 + type RepoTree_TreeEntry struct { 40 + // is_file: Whether this entry is a file 41 + Is_file bool `json:"is_file" cborgen:"is_file"` 42 + // is_subtree: Whether this entry is a directory/subtree 43 + Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 44 + Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 45 + // mode: File mode 46 + Mode string `json:"mode" cborgen:"mode"` 47 + // name: Relative file or directory name 48 + Name string `json:"name" cborgen:"name"` 49 + // size: File size in bytes 50 + Size int64 `json:"size" cborgen:"size"` 51 + } 52 + 53 + // RepoTree calls the XRPC method "sh.tangled.repo.tree". 54 + // 55 + // path: Path within the repository tree 56 + // ref: Git reference (branch, tag, or commit SHA) 57 + // repo: Repository identifier in format 'did:plc:.../repoName' 58 + func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) { 59 + var out RepoTree_Output 60 + 61 + params := map[string]interface{}{} 62 + if path != "" { 63 + params["path"] = path 64 + } 65 + params["ref"] = ref 66 + params["repo"] = repo 67 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil { 68 + return nil, err 69 + } 70 + 71 + return &out, nil 72 + }
+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 + }
+30
api/tangled/tangledowner.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.owner 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + OwnerNSID = "sh.tangled.owner" 15 + ) 16 + 17 + // Owner_Output is the output of a sh.tangled.owner call. 18 + type Owner_Output struct { 19 + Owner string `json:"owner" cborgen:"owner"` 20 + } 21 + 22 + // Owner calls the XRPC method "sh.tangled.owner". 23 + func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) { 24 + var out Owner_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+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
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+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 {
+240 -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 + 706 + // repurpose the read-only column to "needs-upgrade" 707 + runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 708 + _, err := tx.Exec(` 709 + alter table registrations rename column read_only to needs_upgrade; 710 + `) 711 + return err 712 + }) 713 + 714 + // require all knots to upgrade after the release of total xrpc 715 + runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 716 + _, err := tx.Exec(` 717 + update registrations set needs_upgrade = 1; 718 + `) 719 + return err 720 + }) 721 + 722 + // require all knots to upgrade after the release of total xrpc 723 + runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 724 + _, err := tx.Exec(` 725 + alter table spindles add column needs_upgrade integer not null default 0; 726 + `) 727 + if err != nil { 728 + return err 729 + } 730 + 731 + _, err = tx.Exec(` 732 + update spindles set needs_upgrade = 1; 733 + `) 734 + return err 735 + }) 736 + 737 + // remove issue_at from issues and replace with generated column 738 + // 739 + // this requires a full table recreation because stored columns 740 + // cannot be added via alter 741 + // 742 + // couple other changes: 743 + // - columns renamed to be more consistent 744 + // - adds edited and deleted fields 745 + // 746 + // disable foreign-keys for the next migration 747 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 748 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 749 + _, err := tx.Exec(` 750 + create table if not exists issues_new ( 751 + -- identifiers 752 + id integer primary key autoincrement, 753 + did text not null, 754 + rkey text not null, 755 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 756 + 757 + -- at identifiers 758 + repo_at text not null, 759 + 760 + -- content 761 + issue_id integer not null, 762 + title text not null, 763 + body text not null, 764 + open integer not null default 1, 765 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 766 + edited text, -- timestamp 767 + deleted text, -- timestamp 768 + 769 + unique(did, rkey), 770 + unique(repo_at, issue_id), 771 + unique(at_uri), 772 + foreign key (repo_at) references repos(at_uri) on delete cascade 773 + ); 774 + `) 775 + if err != nil { 776 + return err 777 + } 778 + 779 + // transfer data 780 + _, err = tx.Exec(` 781 + insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 782 + select 783 + i.id, 784 + i.owner_did, 785 + i.rkey, 786 + i.repo_at, 787 + i.issue_id, 788 + i.title, 789 + i.body, 790 + i.open, 791 + i.created 792 + from issues i; 793 + `) 794 + if err != nil { 795 + return err 796 + } 797 + 798 + // drop old table 799 + _, err = tx.Exec(`drop table issues`) 800 + if err != nil { 801 + return err 802 + } 803 + 804 + // rename new table 805 + _, err = tx.Exec(`alter table issues_new rename to issues`) 806 + return err 807 + }) 808 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 809 + 810 + // - renames the comments table to 'issue_comments' 811 + // - rework issue comments to update constraints: 812 + // * unique(did, rkey) 813 + // * remove comment-id and just use the global ID 814 + // * foreign key (repo_at, issue_id) 815 + // - new columns 816 + // * column "reply_to" which can be any other comment 817 + // * column "at-uri" which is a generated column 818 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 819 + _, err := tx.Exec(` 820 + create table if not exists issue_comments ( 821 + -- identifiers 822 + id integer primary key autoincrement, 823 + did text not null, 824 + rkey text, 825 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 826 + 827 + -- at identifiers 828 + issue_at text not null, 829 + reply_to text, -- at_uri of parent comment 830 + 831 + -- content 832 + body text not null, 833 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 834 + edited text, 835 + deleted text, 836 + 837 + -- constraints 838 + unique(did, rkey), 839 + unique(at_uri), 840 + foreign key (issue_at) references issues(at_uri) on delete cascade 841 + ); 842 + `) 843 + if err != nil { 844 + return err 845 + } 846 + 847 + // transfer data 848 + _, err = tx.Exec(` 849 + insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 850 + select 851 + c.id, 852 + c.owner_did, 853 + c.rkey, 854 + i.at_uri, -- get at_uri from issues table 855 + c.body, 856 + c.created, 857 + c.edited, 858 + c.deleted 859 + from comments c 860 + join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 861 + `) 862 + if err != nil { 863 + return err 864 + } 865 + 866 + // drop old table 867 + _, err = tx.Exec(`drop table comments`) 868 + return err 869 + }) 870 + 658 871 return &DB{db}, nil 659 872 } 660 873 661 874 type migrationFn = func(*sql.Tx) error 662 875 663 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 664 - tx, err := d.Begin() 876 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 877 + tx, err := c.BeginTx(context.Background(), nil) 665 878 if err != nil { 666 879 return err 667 880 } ··· 699 912 } 700 913 701 914 return nil 915 + } 916 + 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 702 919 } 703 920 704 921 type filter struct {
+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 }
+455 -311
appview/db/issues.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 + "strings" 5 10 "time" 6 11 7 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/api/tangled" 8 14 "tangled.sh/tangled.sh/core/appview/pagination" 9 15 ) 10 16 11 17 type Issue struct { 12 - ID int64 13 - RepoAt syntax.ATURI 14 - OwnerDid string 15 - IssueId int 16 - IssueAt string 17 - Created time.Time 18 - Title string 19 - Body string 20 - Open bool 18 + Id int64 19 + Did string 20 + Rkey string 21 + RepoAt syntax.ATURI 22 + IssueId int 23 + Created time.Time 24 + Edited *time.Time 25 + Deleted *time.Time 26 + Title string 27 + Body string 28 + Open bool 21 29 22 30 // optionally, populate this when querying for reverse mappings 23 31 // like comment counts, parent repo etc. 24 - Metadata *IssueMetadata 32 + Comments []IssueComment 33 + Repo *Repo 25 34 } 26 35 27 - type IssueMetadata struct { 28 - CommentCount int 29 - Repo *Repo 30 - // labels, assignee etc. 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 31 38 } 32 39 33 - type Comment struct { 34 - OwnerDid string 35 - RepoAt syntax.ATURI 36 - Rkey string 37 - Issue int 38 - CommentId int 39 - Body string 40 - Created *time.Time 41 - Deleted *time.Time 42 - Edited *time.Time 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 43 47 } 44 48 45 - func NewIssue(tx *sql.Tx, issue *Issue) error { 46 - defer tx.Rollback() 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 + } 47 55 48 - _, err := tx.Exec(` 49 - insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 50 - values (?, 1) 51 - `, issue.RepoAt) 52 - if err != nil { 53 - return err 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 59 + } 60 + 61 + func (i *Issue) CommentList() []CommentListItem { 62 + // Create a map to quickly find comments by their aturi 63 + toplevel := make(map[string]*CommentListItem) 64 + var replies []*IssueComment 65 + 66 + // collect top level comments into the map 67 + for _, comment := range i.Comments { 68 + if comment.IsTopLevel() { 69 + toplevel[comment.AtUri().String()] = &CommentListItem{ 70 + Self: &comment, 71 + } 72 + } else { 73 + replies = append(replies, &comment) 74 + } 54 75 } 55 76 56 - var nextId int 57 - err = tx.QueryRow(` 58 - update repo_issue_seqs 59 - set next_issue_id = next_issue_id + 1 60 - where repo_at = ? 61 - returning next_issue_id - 1 62 - `, issue.RepoAt).Scan(&nextId) 63 - if err != nil { 64 - return err 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 65 82 } 66 83 67 - issue.IssueId = nextId 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 + } 68 88 69 - res, err := tx.Exec(` 70 - insert into issues (repo_at, owner_did, issue_id, title, body) 71 - values (?, ?, ?, ?, ?) 72 - `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 73 - if err != nil { 74 - return err 89 + // sort everything 90 + sortFunc := func(a, b *IssueComment) bool { 91 + return a.Created.Before(b.Created) 92 + } 93 + sort.Slice(listing, func(i, j int) bool { 94 + return sortFunc(listing[i].Self, listing[j].Self) 95 + }) 96 + for _, r := range listing { 97 + sort.Slice(r.Replies, func(i, j int) bool { 98 + return sortFunc(r.Replies[i], r.Replies[j]) 99 + }) 75 100 } 76 101 77 - lastID, err := res.LastInsertId() 102 + return listing 103 + } 104 + 105 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 78 107 if err != nil { 79 - return err 108 + created = time.Now() 80 109 } 81 - issue.ID = lastID 82 110 83 - if err := tx.Commit(); err != nil { 84 - return err 111 + body := "" 112 + if record.Body != nil { 113 + body = *record.Body 85 114 } 86 115 87 - return nil 116 + return Issue{ 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 124 + } 88 125 } 89 126 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 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 93 137 } 94 138 95 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 96 - var issueAt string 97 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 98 - return issueAt, err 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 99 141 } 100 142 101 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 102 - var ownerDid string 103 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 104 - return ownerDid, err 143 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 + return tangled.RepoIssueComment{ 145 + Body: i.Body, 146 + Issue: i.IssueAt, 147 + CreatedAt: i.Created.Format(time.RFC3339), 148 + ReplyTo: i.ReplyTo, 149 + } 105 150 } 106 151 107 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 108 - var issues []Issue 109 - openValue := 0 110 - if isOpen { 111 - openValue = 1 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 154 + } 155 + 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 158 + if err != nil { 159 + created = time.Now() 112 160 } 113 161 114 - rows, err := e.Query( 115 - ` 116 - with numbered_issue as ( 117 - select 118 - i.id, 119 - i.owner_did, 120 - i.issue_id, 121 - i.created, 122 - i.title, 123 - i.body, 124 - i.open, 125 - count(c.id) as comment_count, 126 - row_number() over (order by i.created desc) as row_num 127 - from 128 - issues i 129 - left join 130 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 131 - where 132 - i.repo_at = ? and i.open = ? 133 - group by 134 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 135 - ) 136 - select 137 - id, 138 - owner_did, 139 - issue_id, 140 - created, 141 - title, 142 - body, 143 - open, 144 - comment_count 145 - from 146 - numbered_issue 147 - where 148 - row_num between ? and ?`, 149 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 150 - if err != nil { 162 + ownerDid := did 163 + 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 151 165 return nil, err 152 166 } 153 - defer rows.Close() 154 167 155 - for rows.Next() { 156 - var issue Issue 157 - var createdAt string 158 - var metadata IssueMetadata 159 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 - if err != nil { 161 - return nil, err 162 - } 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 175 + } 163 176 164 - createdTime, err := time.Parse(time.RFC3339, createdAt) 165 - if err != nil { 166 - return nil, err 177 + return &comment, nil 178 + } 179 + 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 182 + _, err := tx.Exec(` 183 + insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 184 + values (?, 1) 185 + `, issue.RepoAt) 186 + if err != nil { 187 + return err 188 + } 189 + 190 + issues, err := GetIssues( 191 + tx, 192 + FilterEq("did", issue.Did), 193 + FilterEq("rkey", issue.Rkey), 194 + ) 195 + switch { 196 + case err != nil: 197 + return err 198 + case len(issues) == 0: 199 + return createNewIssue(tx, issue) 200 + case len(issues) != 1: // should be unreachable 201 + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 202 + default: 203 + // if content is identical, do not edit 204 + existingIssue := issues[0] 205 + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 206 + return nil 167 207 } 168 - issue.Created = createdTime 169 - issue.Metadata = &metadata 170 208 171 - issues = append(issues, issue) 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 172 212 } 213 + } 173 214 174 - if err := rows.Err(); err != nil { 175 - return nil, err 215 + func createNewIssue(tx *sql.Tx, issue *Issue) error { 216 + // get next issue_id 217 + var newIssueId int 218 + err := tx.QueryRow(` 219 + update repo_issue_seqs 220 + set next_issue_id = next_issue_id + 1 221 + where repo_at = ? 222 + returning next_issue_id - 1 223 + `, issue.RepoAt).Scan(&newIssueId) 224 + if err != nil { 225 + return err 176 226 } 177 227 178 - return issues, nil 228 + // insert new issue 229 + row := tx.QueryRow(` 230 + insert into issues (repo_at, did, rkey, issue_id, title, body) 231 + values (?, ?, ?, ?, ?, ?) 232 + returning rowid, issue_id 233 + `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 234 + 235 + return row.Scan(&issue.Id, &issue.IssueId) 179 236 } 180 237 181 - // timeframe here is directly passed into the sql query filter, and any 182 - // timeframe in the past should be negative; e.g.: "-3 months" 183 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 184 - var issues []Issue 238 + func updateIssue(tx *sql.Tx, issue *Issue) error { 239 + // update existing issue 240 + _, err := tx.Exec(` 241 + update issues 242 + set title = ?, body = ?, edited = ? 243 + where did = ? and rkey = ? 244 + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 245 + return err 246 + } 247 + 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 250 + 251 + var conditions []string 252 + var args []any 253 + 254 + for _, filter := range filters { 255 + conditions = append(conditions, filter.Condition()) 256 + args = append(args, filter.Arg()...) 257 + } 258 + 259 + whereClause := "" 260 + if conditions != nil { 261 + whereClause = " where " + strings.Join(conditions, " and ") 262 + } 263 + 264 + pLower := FilterGte("row_num", page.Offset+1) 265 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 266 + 267 + args = append(args, pLower.Arg()...) 268 + args = append(args, pUpper.Arg()...) 269 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 185 270 186 - rows, err := e.Query( 187 - `select 188 - i.id, 189 - i.owner_did, 190 - i.repo_at, 191 - i.issue_id, 192 - i.created, 193 - i.title, 194 - i.body, 195 - i.open, 196 - r.did, 197 - r.name, 198 - r.knot, 199 - r.rkey, 200 - r.created 201 - from 202 - issues i 203 - join 204 - repos r on i.repo_at = r.at_uri 205 - where 206 - i.owner_did = ? and i.created >= date ('now', ?) 207 - order by 208 - i.created desc`, 209 - ownerDid, timeframe) 271 + query := fmt.Sprintf( 272 + ` 273 + select * from ( 274 + select 275 + id, 276 + did, 277 + rkey, 278 + repo_at, 279 + issue_id, 280 + title, 281 + body, 282 + open, 283 + created, 284 + edited, 285 + deleted, 286 + row_number() over (order by created desc) as row_num 287 + from 288 + issues 289 + %s 290 + ) ranked_issues 291 + %s 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 296 + 297 + rows, err := e.Query(query, args...) 210 298 if err != nil { 211 - return nil, err 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 212 300 } 213 301 defer rows.Close() 214 302 215 303 for rows.Next() { 216 304 var issue Issue 217 - var issueCreatedAt, repoCreatedAt string 218 - var repo Repo 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 219 308 err := rows.Scan( 220 - &issue.ID, 221 - &issue.OwnerDid, 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 222 312 &issue.RepoAt, 223 313 &issue.IssueId, 224 - &issueCreatedAt, 225 314 &issue.Title, 226 315 &issue.Body, 227 316 &issue.Open, 228 - &repo.Did, 229 - &repo.Name, 230 - &repo.Knot, 231 - &repo.Rkey, 232 - &repoCreatedAt, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 233 321 ) 234 322 if err != nil { 235 - return nil, err 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 236 324 } 237 325 238 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 239 - if err != nil { 240 - return nil, err 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 241 328 } 242 - issue.Created = issueCreatedTime 243 329 244 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 245 - if err != nil { 246 - return nil, err 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 247 334 } 248 - repo.Created = repoCreatedTime 249 335 250 - issue.Metadata = &IssueMetadata{ 251 - Repo: &repo, 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 252 340 } 253 341 254 - issues = append(issues, issue) 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 255 344 } 256 345 257 - if err := rows.Err(); err != nil { 258 - return nil, err 346 + // collect reverse repos 347 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 348 + for _, issue := range issueMap { 349 + repoAts = append(repoAts, string(issue.RepoAt)) 259 350 } 260 351 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 353 + if err != nil { 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 + } 356 + 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 361 + 362 + for issueAt := range issueMap { 363 + i := issueMap[issueAt] 364 + r := repoMap[string(i.RepoAt)] 365 + i.Repo = r 366 + } 367 + 368 + // collect comments 369 + issueAts := slices.Collect(maps.Keys(issueMap)) 370 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 371 + if err != nil { 372 + return nil, fmt.Errorf("failed to query comments: %w", err) 373 + } 374 + 375 + for i := range comments { 376 + issueAt := comments[i].IssueAt 377 + if issue, ok := issueMap[issueAt]; ok { 378 + issue.Comments = append(issue.Comments, comments[i]) 379 + } 380 + } 381 + 382 + var issues []Issue 383 + for _, i := range issueMap { 384 + issues = append(issues, *i) 385 + } 386 + 387 + sort.Slice(issues, func(i, j int) bool { 388 + return issues[i].Created.After(issues[j].Created) 389 + }) 390 + 261 391 return issues, nil 262 392 } 263 393 394 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 395 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 396 + } 397 + 264 398 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 = ?` 399 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 400 row := e.QueryRow(query, repoAt, issueId) 267 401 268 402 var issue Issue 269 403 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 404 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 405 if err != nil { 272 406 return nil, err 273 407 } ··· 281 415 return &issue, nil 282 416 } 283 417 284 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 285 - query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 286 - row := e.QueryRow(query, repoAt, issueId) 287 - 288 - var issue Issue 289 - var createdAt string 290 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 418 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 419 + result, err := e.Exec( 420 + `insert into issue_comments ( 421 + did, 422 + rkey, 423 + issue_at, 424 + body, 425 + reply_to, 426 + created, 427 + edited 428 + ) 429 + values (?, ?, ?, ?, ?, ?, null) 430 + on conflict(did, rkey) do update set 431 + issue_at = excluded.issue_at, 432 + body = excluded.body, 433 + edited = case 434 + when 435 + issue_comments.issue_at != excluded.issue_at 436 + or issue_comments.body != excluded.body 437 + or issue_comments.reply_to != excluded.reply_to 438 + then ? 439 + else issue_comments.edited 440 + end`, 441 + c.Did, 442 + c.Rkey, 443 + c.IssueAt, 444 + c.Body, 445 + c.ReplyTo, 446 + c.Created.Format(time.RFC3339), 447 + time.Now().Format(time.RFC3339), 448 + ) 291 449 if err != nil { 292 - return nil, nil, err 450 + return 0, err 293 451 } 294 452 295 - createdTime, err := time.Parse(time.RFC3339, createdAt) 453 + id, err := result.LastInsertId() 296 454 if err != nil { 297 - return nil, nil, err 455 + return 0, err 298 456 } 299 - issue.Created = createdTime 300 457 301 - comments, err := GetComments(e, repoAt, issueId) 302 - if err != nil { 303 - return nil, nil, err 458 + return id, nil 459 + } 460 + 461 + func DeleteIssueComments(e Execer, filters ...filter) error { 462 + var conditions []string 463 + var args []any 464 + for _, filter := range filters { 465 + conditions = append(conditions, filter.Condition()) 466 + args = append(args, filter.Arg()...) 304 467 } 305 468 306 - return &issue, comments, nil 307 - } 469 + whereClause := "" 470 + if conditions != nil { 471 + whereClause = " where " + strings.Join(conditions, " and ") 472 + } 308 473 309 - func NewIssueComment(e Execer, comment *Comment) error { 310 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 311 - _, err := e.Exec( 312 - query, 313 - comment.OwnerDid, 314 - comment.RepoAt, 315 - comment.Rkey, 316 - comment.Issue, 317 - comment.CommentId, 318 - comment.Body, 319 - ) 474 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 475 + 476 + _, err := e.Exec(query, args...) 320 477 return err 321 478 } 322 479 323 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 324 - var comments []Comment 480 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 481 + var comments []IssueComment 482 + 483 + var conditions []string 484 + var args []any 485 + for _, filter := range filters { 486 + conditions = append(conditions, filter.Condition()) 487 + args = append(args, filter.Arg()...) 488 + } 325 489 326 - rows, err := e.Query(` 490 + whereClause := "" 491 + if conditions != nil { 492 + whereClause = " where " + strings.Join(conditions, " and ") 493 + } 494 + 495 + query := fmt.Sprintf(` 327 496 select 328 - owner_did, 329 - issue_id, 330 - comment_id, 497 + id, 498 + did, 331 499 rkey, 500 + issue_at, 501 + reply_to, 332 502 body, 333 503 created, 334 504 edited, 335 505 deleted 336 506 from 337 - comments 338 - where 339 - repo_at = ? and issue_id = ? 340 - order by 341 - created asc`, 342 - repoAt, 343 - issueId, 344 - ) 345 - if err == sql.ErrNoRows { 346 - return []Comment{}, nil 347 - } 507 + issue_comments 508 + %s 509 + `, whereClause) 510 + 511 + rows, err := e.Query(query, args...) 348 512 if err != nil { 349 513 return nil, err 350 514 } 351 - defer rows.Close() 352 515 353 516 for rows.Next() { 354 - var comment Comment 355 - var createdAt string 356 - var deletedAt, editedAt, rkey sql.NullString 357 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 517 + var comment IssueComment 518 + var created string 519 + var rkey, edited, deleted, replyTo sql.Null[string] 520 + err := rows.Scan( 521 + &comment.Id, 522 + &comment.Did, 523 + &rkey, 524 + &comment.IssueAt, 525 + &replyTo, 526 + &comment.Body, 527 + &created, 528 + &edited, 529 + &deleted, 530 + ) 358 531 if err != nil { 359 532 return nil, err 360 533 } 361 534 362 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 363 - if err != nil { 364 - return nil, err 535 + // this is a remnant from old times, newer comments always have rkey 536 + if rkey.Valid { 537 + comment.Rkey = rkey.V 365 538 } 366 - comment.Created = &createdAtTime 367 539 368 - if deletedAt.Valid { 369 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 370 - if err != nil { 371 - return nil, err 540 + if t, err := time.Parse(time.RFC3339, created); err == nil { 541 + comment.Created = t 542 + } 543 + 544 + if edited.Valid { 545 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 546 + comment.Edited = &t 372 547 } 373 - comment.Deleted = &deletedTime 374 548 } 375 549 376 - if editedAt.Valid { 377 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 378 - if err != nil { 379 - return nil, err 550 + if deleted.Valid { 551 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 552 + comment.Deleted = &t 380 553 } 381 - comment.Edited = &editedTime 382 554 } 383 555 384 - if rkey.Valid { 385 - comment.Rkey = rkey.String 556 + if replyTo.Valid { 557 + comment.ReplyTo = &replyTo.V 386 558 } 387 559 388 560 comments = append(comments, comment) 389 561 } 390 562 391 - if err := rows.Err(); err != nil { 563 + if err = rows.Err(); err != nil { 392 564 return nil, err 393 565 } 394 566 395 567 return comments, nil 396 568 } 397 569 398 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 399 - query := ` 400 - select 401 - owner_did, body, rkey, created, deleted, edited 402 - from 403 - comments where repo_at = ? and issue_id = ? and comment_id = ? 404 - ` 405 - row := e.QueryRow(query, repoAt, issueId, commentId) 406 - 407 - var comment Comment 408 - var createdAt string 409 - var deletedAt, editedAt, rkey sql.NullString 410 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 411 - if err != nil { 412 - return nil, err 570 + func DeleteIssues(e Execer, filters ...filter) error { 571 + var conditions []string 572 + var args []any 573 + for _, filter := range filters { 574 + conditions = append(conditions, filter.Condition()) 575 + args = append(args, filter.Arg()...) 413 576 } 414 577 415 - createdTime, err := time.Parse(time.RFC3339, createdAt) 416 - if err != nil { 417 - return nil, err 578 + whereClause := "" 579 + if conditions != nil { 580 + whereClause = " where " + strings.Join(conditions, " and ") 418 581 } 419 - comment.Created = &createdTime 420 582 421 - if deletedAt.Valid { 422 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 423 - if err != nil { 424 - return nil, err 425 - } 426 - comment.Deleted = &deletedTime 427 - } 583 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 584 + _, err := e.Exec(query, args...) 585 + return err 586 + } 428 587 429 - if editedAt.Valid { 430 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 431 - if err != nil { 432 - return nil, err 433 - } 434 - comment.Edited = &editedTime 588 + func CloseIssues(e Execer, filters ...filter) error { 589 + var conditions []string 590 + var args []any 591 + for _, filter := range filters { 592 + conditions = append(conditions, filter.Condition()) 593 + args = append(args, filter.Arg()...) 435 594 } 436 595 437 - if rkey.Valid { 438 - comment.Rkey = rkey.String 596 + whereClause := "" 597 + if conditions != nil { 598 + whereClause = " where " + strings.Join(conditions, " and ") 439 599 } 440 600 441 - comment.RepoAt = repoAt 442 - comment.Issue = issueId 443 - comment.CommentId = commentId 444 - 445 - return &comment, nil 446 - } 447 - 448 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 449 - _, err := e.Exec( 450 - ` 451 - update comments 452 - set body = ?, 453 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 454 - where repo_at = ? and issue_id = ? and comment_id = ? 455 - `, newBody, repoAt, issueId, commentId) 601 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 602 + _, err := e.Exec(query, args...) 456 603 return err 457 604 } 458 605 459 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 460 - _, err := e.Exec( 461 - ` 462 - update comments 463 - set body = "", 464 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 465 - where repo_at = ? and issue_id = ? and comment_id = ? 466 - `, repoAt, issueId, commentId) 467 - return err 468 - } 606 + func ReopenIssues(e Execer, filters ...filter) error { 607 + var conditions []string 608 + var args []any 609 + for _, filter := range filters { 610 + conditions = append(conditions, filter.Condition()) 611 + args = append(args, filter.Arg()...) 612 + } 469 613 470 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 471 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 472 - return err 473 - } 614 + whereClause := "" 615 + if conditions != nil { 616 + whereClause = " where " + strings.Join(conditions, " and ") 617 + } 474 618 475 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 476 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 619 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 620 + _, err := e.Exec(query, args...) 477 621 return err 478 622 } 479 623
-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
··· 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;
+23 -10
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 ··· 118 132 *items = append(*items, &pull) 119 133 } 120 134 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 122 140 if err != nil { 123 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 142 } ··· 137 155 *items = append(*items, &issue) 138 156 } 139 157 140 - repos, err := GetAllReposByDid(e, forDid) 158 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 141 159 if err != nil { 142 160 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 161 } ··· 348 366 return tx.Commit() 349 367 } 350 368 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 369 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 352 370 var conditions []string 353 371 var args []any 354 372 for _, filter := range filters { ··· 448 466 idxs[did] = idx + 1 449 467 } 450 468 451 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 469 + return profileMap, nil 457 470 } 458 471 459 472 func GetProfile(e Execer, did string) (*Profile, error) { ··· 577 590 } 578 591 579 592 // ensure all pinned repos are either own repos or collaborating repos 580 - repos, err := GetAllReposByDid(e, profile.Did) 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 581 594 if err != nil { 582 595 log.Printf("getting repos for %s: %s", profile.Did, err) 583 596 }
+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
··· 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
··· 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 {
+94 -130
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 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 18 19 } 19 20 20 21 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 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) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 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 + NeedsUpgrade 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, needs_upgrade 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(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var needsUpgrade int 84 + var reg Registration 52 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 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 - } 62 - 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 88 + return nil, err 66 89 } 67 - } 68 90 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 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 93 + } 77 94 78 - err := e.QueryRow(` 79 - select id, domain, did, created, registered from registrations 80 - where domain = ? 81 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 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 needsUpgrade != 0 { 102 + reg.NeedsUpgrade = 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 &registration, 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'), needs_upgrade = 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 160 - } 161 - 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 - } 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()...) 172 142 } 173 143 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 - } 180 - 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) 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 187 150 151 + _, err := e.Exec(query, args...) 188 152 return err 189 153 }
+36 -140
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 322 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 323 - var repos []Repo 324 - 325 - rows, err := e.Query( 326 - `select 327 - r.did, 328 - r.name, 329 - r.knot, 330 - r.rkey, 331 - r.description, 332 - r.created, 333 - count(s.id) as star_count, 334 - r.source 335 - from 336 - repos r 337 - left join 338 - stars s on r.at_uri = s.repo_at 339 - where 340 - r.did = ? 341 - group by 342 - r.at_uri 343 - order by r.created desc`, 344 - did) 345 - if err != nil { 346 - return nil, err 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()...) 347 294 } 348 - defer rows.Close() 349 - 350 - for rows.Next() { 351 - var repo Repo 352 - var repoStats RepoStats 353 - var createdAt string 354 - var nullableDescription sql.NullString 355 - var nullableSource sql.NullString 356 - 357 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 358 - if err != nil { 359 - return nil, err 360 - } 361 - 362 - if nullableDescription.Valid { 363 - repo.Description = nullableDescription.String 364 - } 365 295 366 - if nullableSource.Valid { 367 - repo.Source = nullableSource.String 368 - } 369 - 370 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 371 - if err != nil { 372 - repo.Created = time.Now() 373 - } else { 374 - repo.Created = createdAtTime 375 - } 376 - 377 - repo.RepoStats = &repoStats 378 - 379 - repos = append(repos, repo) 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 380 299 } 381 300 382 - if err := rows.Err(); err != nil { 383 - return nil, err 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 384 307 } 385 308 386 - return repos, nil 309 + return count, nil 387 310 } 388 311 389 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 391 314 var description, spindle sql.NullString 392 315 393 316 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 317 + select did, name, knot, created, description, spindle, rkey 395 318 from repos 396 319 where did = ? and name = ? 397 320 `, ··· 400 323 ) 401 324 402 325 var createdAt string 403 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 326 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 404 327 return nil, err 405 328 } 406 329 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 421 344 var repo Repo 422 345 var nullableDescription sql.NullString 423 346 424 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 347 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 425 348 426 349 var createdAt string 427 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 350 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 428 351 return nil, err 429 352 } 430 353 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 444 367 `insert into repos 445 368 (did, name, knot, rkey, at_uri, description, source) 446 369 values (?, ?, ?, ?, ?, ?, ?)`, 447 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 370 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 448 371 ) 449 372 return err 450 373 } ··· 467 390 var repos []Repo 468 391 469 392 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, 393 + `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 394 + from repos r 395 + left join collaborators c on r.at_uri = c.repo_at 396 + where (r.did = ? or c.subject_did = ?) 397 + and r.source is not null 398 + and r.source != '' 399 + order by r.created desc`, 400 + did, did, 475 401 ) 476 402 if err != nil { 477 403 return nil, err ··· 484 410 var nullableDescription sql.NullString 485 411 var nullableSource sql.NullString 486 412 487 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 413 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 488 414 if err != nil { 489 415 return nil, err 490 416 } ··· 521 447 var nullableSource sql.NullString 522 448 523 449 row := e.QueryRow( 524 - `select did, name, knot, rkey, description, created, at_uri, source 450 + `select did, name, knot, rkey, description, created, source 525 451 from repos 526 452 where did = ? and name = ? and source is not null and source != ''`, 527 453 did, name, 528 454 ) 529 455 530 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 456 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 531 457 if err != nil { 532 458 return nil, err 533 459 } ··· 556 482 return err 557 483 } 558 484 559 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 485 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 560 486 _, err := e.Exec( 561 487 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 562 488 return err ··· 568 494 IssueCount IssueCount 569 495 PullCount PullCount 570 496 } 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 - }
+14 -7
appview/db/spindle.go
··· 10 10 ) 11 11 12 12 type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + NeedsUpgrade bool 18 19 } 19 20 20 21 type SpindleMember struct { ··· 42 43 } 43 44 44 45 query := fmt.Sprintf( 45 - `select id, owner, instance, verified, created 46 + `select id, owner, instance, verified, created, needs_upgrade 46 47 from spindles 47 48 %s 48 49 order by created ··· 61 62 var spindle Spindle 62 63 var createdAt string 63 64 var verified sql.NullString 65 + var needsUpgrade int 64 66 65 67 if err := rows.Scan( 66 68 &spindle.Id, ··· 68 70 &spindle.Instance, 69 71 &verified, 70 72 &createdAt, 73 + &needsUpgrade, 71 74 ); err != nil { 72 75 return nil, err 73 76 } ··· 86 89 spindle.Verified = &t 87 90 } 88 91 92 + if needsUpgrade != 0 { 93 + spindle.NeedsUpgrade = true 94 + } 95 + 89 96 spindles = append(spindles, spindle) 90 97 } 91 98 ··· 115 122 whereClause = " where " + strings.Join(conditions, " and ") 116 123 } 117 124 118 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 125 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 119 126 120 127 res, err := e.Exec(query, args...) 121 128 if err != nil {
+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
··· 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 {
+17 -35
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 - type FollowStats struct { 24 - Followers int 25 - Following int 26 - } 27 - 28 - const Limit = 50 29 - 30 23 // TODO: this gathers heterogenous events from different sources and aggregates 31 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 32 - func MakeTimeline(e Execer) ([]TimelineEvent, error) { 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 33 26 var events []TimelineEvent 34 27 35 - repos, err := getTimelineRepos(e) 28 + repos, err := getTimelineRepos(e, limit) 36 29 if err != nil { 37 30 return nil, err 38 31 } 39 32 40 - stars, err := getTimelineStars(e) 33 + stars, err := getTimelineStars(e, limit) 41 34 if err != nil { 42 35 return nil, err 43 36 } 44 37 45 - follows, err := getTimelineFollows(e) 38 + follows, err := getTimelineFollows(e, limit) 46 39 if err != nil { 47 40 return nil, err 48 41 } ··· 56 49 }) 57 50 58 51 // Limit the slice to 100 events 59 - if len(events) > Limit { 60 - events = events[:Limit] 52 + if len(events) > limit { 53 + events = events[:limit] 61 54 } 62 55 63 56 return events, nil 64 57 } 65 58 66 - func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 67 - repos, err := GetRepos(e, Limit) 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 68 61 if err != nil { 69 62 return nil, err 70 63 } ··· 109 102 return events, nil 110 103 } 111 104 112 - func getTimelineStars(e Execer) ([]TimelineEvent, error) { 113 - stars, err := GetStars(e, Limit) 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 114 107 if err != nil { 115 108 return nil, err 116 109 } ··· 136 129 return events, nil 137 130 } 138 131 139 - func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 - follows, err := GetAllFollows(e, Limit) 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 141 134 if err != nil { 142 135 return nil, err 143 136 } ··· 151 144 return nil, nil 152 145 } 153 146 154 - profileMap := make(map[string]Profile) 155 147 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 148 if err != nil { 157 149 return nil, err 158 150 } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 - } 162 151 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 - } 152 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 153 + if err != nil { 154 + return nil, err 173 155 } 174 156 175 157 var events []TimelineEvent 176 158 for _, f := range follows { 177 - profile, _ := profileMap[f.SubjectDid] 159 + profile, _ := profiles[f.SubjectDid] 178 160 followStatMap, _ := followStatMap[f.SubjectDid] 179 161 180 162 events = append(events, TimelineEvent{ 181 163 Follow: &f, 182 - Profile: &profile, 164 + Profile: profile, 183 165 FollowStats: &followStatMap, 184 166 EventAt: f.FollowedAt, 185 167 })
+297 -10
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + 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/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 18 20 "tangled.sh/tangled.sh/core/idresolver" 19 21 "tangled.sh/tangled.sh/core/rbac" 20 22 ) ··· 25 27 IdResolver *idresolver.Resolver 26 28 Config *config.Config 27 29 Logger *slog.Logger 30 + Validator *validator.Validator 28 31 } 29 32 30 33 type processFunc func(ctx context.Context, e *models.Event) error ··· 61 64 case tangled.ActorProfileNSID: 62 65 err = i.ingestProfile(e) 63 66 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 67 + err = i.ingestSpindleMember(ctx, e) 65 68 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 69 + err = i.ingestSpindle(ctx, e) 70 + case tangled.KnotMemberNSID: 71 + err = i.ingestKnotMember(e) 72 + case tangled.KnotNSID: 73 + err = i.ingestKnot(e) 67 74 case tangled.StringNSID: 68 75 err = i.ingestString(e) 76 + case tangled.RepoIssueNSID: 77 + err = i.ingestIssue(ctx, e) 78 + case tangled.RepoIssueCommentNSID: 79 + err = i.ingestIssueComment(e) 69 80 } 70 81 l = i.Logger.With("nsid", e.Commit.Collection) 71 82 } 72 83 73 84 if err != nil { 74 - l.Error("error ingesting record", "err", err) 85 + l.Debug("error ingesting record", "err", err) 75 86 } 76 87 77 - return err 88 + return nil 78 89 } 79 90 } 80 91 ··· 336 347 return nil 337 348 } 338 349 339 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 350 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 340 351 did := e.Did 341 352 var err error 342 353 ··· 359 370 return fmt.Errorf("failed to enforce permissions: %w", err) 360 371 } 361 372 362 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 373 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 363 374 if err != nil { 364 375 return err 365 376 } ··· 442 453 return nil 443 454 } 444 455 445 - func (i *Ingester) ingestSpindle(e *models.Event) error { 456 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 446 457 did := e.Did 447 458 var err error 448 459 ··· 475 486 return err 476 487 } 477 488 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 489 + err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 479 490 if err != nil { 480 491 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 492 return err 482 493 } 483 494 484 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 495 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 485 496 if err != nil { 486 497 return fmt.Errorf("failed to mark verified: %w", err) 487 498 } ··· 609 620 610 621 return nil 611 622 } 623 + 624 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 625 + did := e.Did 626 + var err error 627 + 628 + l := i.Logger.With("handler", "ingestKnotMember") 629 + l = l.With("nsid", e.Commit.Collection) 630 + 631 + switch e.Commit.Operation { 632 + case models.CommitOperationCreate: 633 + raw := json.RawMessage(e.Commit.Record) 634 + record := tangled.KnotMember{} 635 + err = json.Unmarshal(raw, &record) 636 + if err != nil { 637 + l.Error("invalid record", "err", err) 638 + return err 639 + } 640 + 641 + // only knot owner can invite to knots 642 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 643 + if err != nil || !ok { 644 + return fmt.Errorf("failed to enforce permissions: %w", err) 645 + } 646 + 647 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 648 + if err != nil { 649 + return err 650 + } 651 + 652 + if memberId.Handle.IsInvalidHandle() { 653 + return err 654 + } 655 + 656 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 657 + if err != nil { 658 + return fmt.Errorf("failed to update ACLs: %w", err) 659 + } 660 + 661 + l.Info("added knot member") 662 + case models.CommitOperationDelete: 663 + // we don't store knot members in a table (like we do for spindle) 664 + // and we can't remove this just yet. possibly fixed if we switch 665 + // to either: 666 + // 1. a knot_members table like with spindle and store the rkey 667 + // 2. use the knot host as the rkey 668 + // 669 + // TODO: implement member deletion 670 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 671 + } 672 + 673 + return nil 674 + } 675 + 676 + func (i *Ingester) ingestKnot(e *models.Event) error { 677 + did := e.Did 678 + var err error 679 + 680 + l := i.Logger.With("handler", "ingestKnot") 681 + l = l.With("nsid", e.Commit.Collection) 682 + 683 + switch e.Commit.Operation { 684 + case models.CommitOperationCreate: 685 + raw := json.RawMessage(e.Commit.Record) 686 + record := tangled.Knot{} 687 + err = json.Unmarshal(raw, &record) 688 + if err != nil { 689 + l.Error("invalid record", "err", err) 690 + return err 691 + } 692 + 693 + domain := e.Commit.RKey 694 + 695 + ddb, ok := i.Db.Execer.(*db.DB) 696 + if !ok { 697 + return fmt.Errorf("failed to index profile record, invalid db cast") 698 + } 699 + 700 + err := db.AddKnot(ddb, domain, did) 701 + if err != nil { 702 + l.Error("failed to add knot to db", "err", err, "domain", domain) 703 + return err 704 + } 705 + 706 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 707 + if err != nil { 708 + l.Error("failed to verify knot", "err", err, "domain", domain) 709 + return err 710 + } 711 + 712 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 713 + if err != nil { 714 + return fmt.Errorf("failed to mark verified: %w", err) 715 + } 716 + 717 + return nil 718 + 719 + case models.CommitOperationDelete: 720 + domain := e.Commit.RKey 721 + 722 + ddb, ok := i.Db.Execer.(*db.DB) 723 + if !ok { 724 + return fmt.Errorf("failed to index knot record, invalid db cast") 725 + } 726 + 727 + // get record from db first 728 + registrations, err := db.GetRegistrations( 729 + ddb, 730 + db.FilterEq("domain", domain), 731 + db.FilterEq("did", did), 732 + ) 733 + if err != nil { 734 + return fmt.Errorf("failed to get registration: %w", err) 735 + } 736 + if len(registrations) != 1 { 737 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 738 + } 739 + registration := registrations[0] 740 + 741 + tx, err := ddb.Begin() 742 + if err != nil { 743 + return err 744 + } 745 + defer func() { 746 + tx.Rollback() 747 + i.Enforcer.E.LoadPolicy() 748 + }() 749 + 750 + err = db.DeleteKnot( 751 + tx, 752 + db.FilterEq("did", did), 753 + db.FilterEq("domain", domain), 754 + ) 755 + if err != nil { 756 + return err 757 + } 758 + 759 + if registration.Registered != nil { 760 + err = i.Enforcer.RemoveKnot(domain) 761 + if err != nil { 762 + return err 763 + } 764 + } 765 + 766 + err = tx.Commit() 767 + if err != nil { 768 + return err 769 + } 770 + 771 + err = i.Enforcer.E.SavePolicy() 772 + if err != nil { 773 + return err 774 + } 775 + } 776 + 777 + return nil 778 + } 779 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 780 + did := e.Did 781 + rkey := e.Commit.RKey 782 + 783 + var err error 784 + 785 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 786 + l.Info("ingesting record") 787 + 788 + ddb, ok := i.Db.Execer.(*db.DB) 789 + if !ok { 790 + return fmt.Errorf("failed to index issue record, invalid db cast") 791 + } 792 + 793 + switch e.Commit.Operation { 794 + case models.CommitOperationCreate, models.CommitOperationUpdate: 795 + raw := json.RawMessage(e.Commit.Record) 796 + record := tangled.RepoIssue{} 797 + err = json.Unmarshal(raw, &record) 798 + if err != nil { 799 + l.Error("invalid record", "err", err) 800 + return err 801 + } 802 + 803 + issue := db.IssueFromRecord(did, rkey, record) 804 + 805 + if err := i.Validator.ValidateIssue(&issue); err != nil { 806 + return fmt.Errorf("failed to validate issue: %w", err) 807 + } 808 + 809 + tx, err := ddb.BeginTx(ctx, nil) 810 + if err != nil { 811 + l.Error("failed to begin transaction", "err", err) 812 + return err 813 + } 814 + defer tx.Rollback() 815 + 816 + err = db.PutIssue(tx, &issue) 817 + if err != nil { 818 + l.Error("failed to create issue", "err", err) 819 + return err 820 + } 821 + 822 + err = tx.Commit() 823 + if err != nil { 824 + l.Error("failed to commit txn", "err", err) 825 + return err 826 + } 827 + 828 + return nil 829 + 830 + case models.CommitOperationDelete: 831 + if err := db.DeleteIssues( 832 + ddb, 833 + db.FilterEq("did", did), 834 + db.FilterEq("rkey", rkey), 835 + ); err != nil { 836 + l.Error("failed to delete", "err", err) 837 + return fmt.Errorf("failed to delete issue record: %w", err) 838 + } 839 + 840 + return nil 841 + } 842 + 843 + return nil 844 + } 845 + 846 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 847 + did := e.Did 848 + rkey := e.Commit.RKey 849 + 850 + var err error 851 + 852 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 853 + l.Info("ingesting record") 854 + 855 + ddb, ok := i.Db.Execer.(*db.DB) 856 + if !ok { 857 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 858 + } 859 + 860 + switch e.Commit.Operation { 861 + case models.CommitOperationCreate, models.CommitOperationUpdate: 862 + raw := json.RawMessage(e.Commit.Record) 863 + record := tangled.RepoIssueComment{} 864 + err = json.Unmarshal(raw, &record) 865 + if err != nil { 866 + return fmt.Errorf("invalid record: %w", err) 867 + } 868 + 869 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 870 + if err != nil { 871 + return fmt.Errorf("failed to parse comment from record: %w", err) 872 + } 873 + 874 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 875 + return fmt.Errorf("failed to validate comment: %w", err) 876 + } 877 + 878 + _, err = db.AddIssueComment(ddb, *comment) 879 + if err != nil { 880 + return fmt.Errorf("failed to create issue comment: %w", err) 881 + } 882 + 883 + return nil 884 + 885 + case models.CommitOperationDelete: 886 + if err := db.DeleteIssueComments( 887 + ddb, 888 + db.FilterEq("did", did), 889 + db.FilterEq("rkey", rkey), 890 + ); err != nil { 891 + return fmt.Errorf("failed to delete issue comment record: %w", err) 892 + } 893 + 894 + return nil 895 + } 896 + 897 + return nil 898 + }
+474 -332
appview/issues/issues.go
··· 1 1 package issues 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 4 7 "fmt" 5 8 "log" 6 - mathrand "math/rand/v2" 9 + "log/slog" 7 10 "net/http" 8 11 "slices" 9 - "strconv" 10 12 "time" 11 13 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/data" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 15 16 lexutil "github.com/bluesky-social/indigo/lex/util" 16 17 "github.com/go-chi/chi/v5" ··· 23 24 "tangled.sh/tangled.sh/core/appview/pages" 24 25 "tangled.sh/tangled.sh/core/appview/pagination" 25 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 26 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 27 31 "tangled.sh/tangled.sh/core/tid" 28 32 ) 29 33 ··· 35 39 db *db.DB 36 40 config *config.Config 37 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 38 44 } 39 45 40 46 func New( ··· 45 51 db *db.DB, 46 52 config *config.Config, 47 53 notifier notify.Notifier, 54 + validator *validator.Validator, 48 55 ) *Issues { 49 56 return &Issues{ 50 57 oauth: oauth, ··· 54 61 db: db, 55 62 config: config, 56 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 57 66 } 58 67 } 59 68 60 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 61 71 user := rp.oauth.GetUser(r) 62 72 f, err := rp.repoResolver.Resolve(r) 63 73 if err != nil { ··· 65 75 return 66 76 } 67 77 68 - issueId := chi.URLParam(r, "issue") 69 - issueIdInt, err := strconv.Atoi(issueId) 70 - if err != nil { 71 - http.Error(w, "bad issue id", http.StatusBadRequest) 72 - log.Println("failed to parse issue id", err) 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 73 82 return 74 83 } 75 84 76 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 85 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 77 86 if err != nil { 78 - log.Println("failed to get issue and comments", err) 79 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 80 - return 81 - } 82 - 83 - reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 - if err != nil { 85 - log.Println("failed to get issue reactions") 86 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + l.Error("failed to get issue reactions", "err", err) 87 88 } 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 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 95 + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 100 + OrderedReactionKinds: db.OrderedReactionKinds, 101 + Reactions: reactionCountMap, 102 + UserReacted: userReactions, 103 + }) 104 + } 105 + 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 108 + user := rp.oauth.GetUser(r) 109 + f, err := rp.repoResolver.Resolve(r) 95 110 if err != nil { 96 - log.Println("failed to resolve issue owner", err) 111 + log.Println("failed to get repo and knot", err) 112 + return 97 113 } 98 114 99 - identsToResolve := make([]string, len(comments)) 100 - for i, comment := range comments { 101 - identsToResolve[i] = comment.OwnerDid 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 119 + return 102 120 } 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() 121 + 122 + switch r.Method { 123 + case http.MethodGet: 124 + rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(user), 127 + Issue: issue, 128 + }) 129 + case http.MethodPost: 130 + noticeId := "issues" 131 + newIssue := issue 132 + newIssue.Title = r.FormValue("title") 133 + newIssue.Body = r.FormValue("body") 134 + 135 + if err := rp.validator.ValidateIssue(newIssue); err != nil { 136 + l.Error("validation error", "err", err) 137 + rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 138 + return 110 139 } 111 - } 112 140 113 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 114 - LoggedInUser: user, 115 - RepoInfo: f.RepoInfo(user), 116 - Issue: *issue, 117 - Comments: comments, 141 + newRecord := newIssue.AsRecord() 118 142 119 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 120 - DidHandleMap: didHandleMap, 143 + // edit an atproto record 144 + client, err := rp.oauth.AuthorizedClient(r) 145 + if err != nil { 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 148 + return 149 + } 121 150 122 - OrderedReactionKinds: db.OrderedReactionKinds, 123 - Reactions: reactionCountMap, 124 - UserReacted: userReactions, 125 - }) 151 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 + if err != nil { 153 + l.Error("failed to get record", "err", err) 154 + rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 + return 156 + } 157 + 158 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 159 + Collection: tangled.RepoIssueNSID, 160 + Repo: user.Did, 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 163 + Record: &lexutil.LexiconTypeDecoder{ 164 + Val: &newRecord, 165 + }, 166 + }) 167 + if err != nil { 168 + l.Error("failed to edit record on PDS", "err", err) 169 + rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 170 + return 171 + } 172 + 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 175 + if err != nil { 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 + return 179 + } 180 + defer tx.Rollback() 181 + 182 + err = db.PutIssue(tx, newIssue) 183 + if err != nil { 184 + log.Println("failed to edit issue", err) 185 + rp.pages.Notice(w, "issues", "Failed to edit issue.") 186 + return 187 + } 188 + 189 + if err = tx.Commit(); err != nil { 190 + l.Error("failed to edit issue", "err", err) 191 + rp.pages.Notice(w, "issues", "Failed to cedit issue.") 192 + return 193 + } 126 194 195 + rp.pages.HxRefresh(w) 196 + } 127 197 } 128 198 129 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 199 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 + l := rp.logger.With("handler", "DeleteIssue") 201 + noticeId := "issue-actions-error" 202 + 130 203 user := rp.oauth.GetUser(r) 204 + 131 205 f, err := rp.repoResolver.Resolve(r) 132 206 if err != nil { 133 - log.Println("failed to get repo and knot", err) 207 + l.Error("failed to get repo and knot", "err", err) 208 + return 209 + } 210 + 211 + issue, ok := r.Context().Value("issue").(*db.Issue) 212 + if !ok { 213 + l.Error("failed to get issue") 214 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 134 215 return 135 216 } 217 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 136 218 137 - issueId := chi.URLParam(r, "issue") 138 - issueIdInt, err := strconv.Atoi(issueId) 219 + // delete from PDS 220 + client, err := rp.oauth.AuthorizedClient(r) 139 221 if err != nil { 140 - http.Error(w, "bad issue id", http.StatusBadRequest) 141 - log.Println("failed to parse issue id", err) 222 + log.Println("failed to get authorized client", err) 223 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 224 + return 225 + } 226 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 227 + Collection: tangled.RepoIssueNSID, 228 + Repo: issue.Did, 229 + Rkey: issue.Rkey, 230 + }) 231 + if err != nil { 232 + // TODO: transact this better 233 + l.Error("failed to delete issue from PDS", "err", err) 234 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 142 235 return 143 236 } 144 237 145 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 238 + // delete from db 239 + if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 240 + l.Error("failed to delete issue", "err", err) 241 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 242 + return 243 + } 244 + 245 + // return to all issues page 246 + rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 247 + } 248 + 249 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 250 + l := rp.logger.With("handler", "CloseIssue") 251 + user := rp.oauth.GetUser(r) 252 + f, err := rp.repoResolver.Resolve(r) 146 253 if err != nil { 147 - log.Println("failed to get issue", err) 148 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 254 + l.Error("failed to get repo and knot", "err", err) 255 + return 256 + } 257 + 258 + issue, ok := r.Context().Value("issue").(*db.Issue) 259 + if !ok { 260 + l.Error("failed to get issue") 261 + rp.pages.Error404(w) 149 262 return 150 263 } 151 264 ··· 156 269 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 157 270 return user.Did == collab.Did 158 271 }) 159 - isIssueOwner := user.Did == issue.OwnerDid 272 + isIssueOwner := user.Did == issue.Did 160 273 161 274 // TODO: make this more granular 162 275 if isIssueOwner || isCollaborator { 163 - 164 - closed := tangled.RepoIssueStateClosed 165 - 166 - client, err := rp.oauth.AuthorizedClient(r) 167 - if err != nil { 168 - log.Println("failed to get authorized client", err) 169 - return 170 - } 171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 - Collection: tangled.RepoIssueStateNSID, 173 - Repo: user.Did, 174 - Rkey: tid.TID(), 175 - Record: &lexutil.LexiconTypeDecoder{ 176 - Val: &tangled.RepoIssueState{ 177 - Issue: issue.IssueAt, 178 - State: closed, 179 - }, 180 - }, 181 - }) 182 - 183 - if err != nil { 184 - log.Println("failed to update issue state", err) 185 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 186 - return 187 - } 188 - 189 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 276 + err = db.CloseIssues( 277 + rp.db, 278 + db.FilterEq("id", issue.Id), 279 + ) 190 280 if err != nil { 191 281 log.Println("failed to close issue", err) 192 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 193 283 return 194 284 } 195 285 196 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 197 287 return 198 288 } else { 199 289 log.Println("user is not permitted to close issue") ··· 203 293 } 204 294 205 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 206 297 user := rp.oauth.GetUser(r) 207 298 f, err := rp.repoResolver.Resolve(r) 208 299 if err != nil { ··· 210 301 return 211 302 } 212 303 213 - issueId := chi.URLParam(r, "issue") 214 - issueIdInt, err := strconv.Atoi(issueId) 215 - if err != nil { 216 - http.Error(w, "bad issue id", http.StatusBadRequest) 217 - log.Println("failed to parse issue id", err) 218 - return 219 - } 220 - 221 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 222 - if err != nil { 223 - log.Println("failed to get issue", err) 224 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 225 308 return 226 309 } 227 310 ··· 232 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 233 316 return user.Did == collab.Did 234 317 }) 235 - isIssueOwner := user.Did == issue.OwnerDid 318 + isIssueOwner := user.Did == issue.Did 236 319 237 320 if isCollaborator || isIssueOwner { 238 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 239 325 if err != nil { 240 326 log.Println("failed to reopen issue", err) 241 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 242 328 return 243 329 } 244 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 245 331 return 246 332 } else { 247 333 log.Println("user is not the owner of the repo") ··· 251 337 } 252 338 253 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 254 341 user := rp.oauth.GetUser(r) 255 342 f, err := rp.repoResolver.Resolve(r) 256 343 if err != nil { 257 - log.Println("failed to get repo and knot", err) 344 + l.Error("failed to get repo and knot", "err", err) 258 345 return 259 346 } 260 347 261 - issueId := chi.URLParam(r, "issue") 262 - issueIdInt, err := strconv.Atoi(issueId) 263 - if err != nil { 264 - http.Error(w, "bad issue id", http.StatusBadRequest) 265 - log.Println("failed to parse issue id", err) 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 266 352 return 267 353 } 268 354 269 - switch r.Method { 270 - case http.MethodPost: 271 - body := r.FormValue("body") 272 - if body == "" { 273 - rp.pages.Notice(w, "issue", "Body is required") 274 - return 275 - } 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 276 360 277 - commentId := mathrand.IntN(1000000) 278 - rkey := tid.TID() 279 - 280 - err := db.NewIssueComment(rp.db, &db.Comment{ 281 - OwnerDid: user.Did, 282 - RepoAt: f.RepoAt, 283 - Issue: issueIdInt, 284 - CommentId: commentId, 285 - Body: body, 286 - Rkey: rkey, 287 - }) 288 - if err != nil { 289 - log.Println("failed to create comment", err) 290 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 291 - return 292 - } 293 - 294 - createdAt := time.Now().Format(time.RFC3339) 295 - commentIdInt64 := int64(commentId) 296 - ownerDid := user.Did 297 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 298 - if err != nil { 299 - log.Println("failed to get issue at", err) 300 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 301 - return 302 - } 303 - 304 - atUri := f.RepoAt.String() 305 - client, err := rp.oauth.AuthorizedClient(r) 306 - if err != nil { 307 - log.Println("failed to get authorized client", err) 308 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 309 - return 310 - } 311 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 312 - Collection: tangled.RepoIssueCommentNSID, 313 - Repo: user.Did, 314 - Rkey: rkey, 315 - Record: &lexutil.LexiconTypeDecoder{ 316 - Val: &tangled.RepoIssueComment{ 317 - Repo: &atUri, 318 - Issue: issueAt, 319 - CommentId: &commentIdInt64, 320 - Owner: &ownerDid, 321 - Body: body, 322 - CreatedAt: createdAt, 323 - }, 324 - }, 325 - }) 326 - if err != nil { 327 - log.Println("failed to create comment", err) 328 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 329 - return 330 - } 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 331 366 332 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 367 + comment := db.IssueComment{ 368 + Did: user.Did, 369 + Rkey: tid.TID(), 370 + IssueAt: issue.AtUri().String(), 371 + ReplyTo: replyTo, 372 + Body: body, 373 + Created: time.Now(), 374 + } 375 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 376 + l.Error("failed to validate comment", "err", err) 377 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 333 378 return 334 379 } 335 - } 380 + record := comment.AsRecord() 336 381 337 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 338 - user := rp.oauth.GetUser(r) 339 - f, err := rp.repoResolver.Resolve(r) 382 + client, err := rp.oauth.AuthorizedClient(r) 340 383 if err != nil { 341 - log.Println("failed to get repo and knot", err) 384 + l.Error("failed to get authorized client", "err", err) 385 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 342 386 return 343 387 } 344 388 345 - issueId := chi.URLParam(r, "issue") 346 - issueIdInt, err := strconv.Atoi(issueId) 389 + // create a record first 390 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 391 + Collection: tangled.RepoIssueCommentNSID, 392 + Repo: comment.Did, 393 + Rkey: comment.Rkey, 394 + Record: &lexutil.LexiconTypeDecoder{ 395 + Val: &record, 396 + }, 397 + }) 347 398 if err != nil { 348 - http.Error(w, "bad issue id", http.StatusBadRequest) 349 - log.Println("failed to parse issue id", err) 399 + l.Error("failed to create comment", "err", err) 400 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 350 401 return 351 402 } 403 + atUri := resp.Uri 404 + defer func() { 405 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 406 + l.Error("rollback failed", "err", err) 407 + } 408 + }() 352 409 353 - commentId := chi.URLParam(r, "comment_id") 354 - commentIdInt, err := strconv.Atoi(commentId) 410 + commentId, err := db.AddIssueComment(rp.db, comment) 355 411 if err != nil { 356 - http.Error(w, "bad comment id", http.StatusBadRequest) 357 - log.Println("failed to parse issue id", err) 412 + l.Error("failed to create comment", "err", err) 413 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 358 414 return 359 415 } 360 416 361 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 417 + // reset atUri to make rollback a no-op 418 + atUri = "" 419 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 420 + } 421 + 422 + func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 424 + user := rp.oauth.GetUser(r) 425 + f, err := rp.repoResolver.Resolve(r) 362 426 if err != nil { 363 - log.Println("failed to get issue", err) 364 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 427 + l.Error("failed to get repo and knot", "err", err) 365 428 return 366 429 } 367 430 368 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 369 - if err != nil { 370 - http.Error(w, "bad comment id", http.StatusBadRequest) 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 371 435 return 372 436 } 373 437 374 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 375 443 if err != nil { 376 - log.Println("failed to resolve did") 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 377 446 return 378 447 } 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() 448 + if len(comments) != 1 { 449 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 450 + http.Error(w, "invalid comment id", http.StatusBadRequest) 451 + return 385 452 } 453 + comment := comments[0] 386 454 387 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 388 456 LoggedInUser: user, 389 457 RepoInfo: f.RepoInfo(user), 390 - DidHandleMap: didHandleMap, 391 458 Issue: issue, 392 - Comment: comment, 459 + Comment: &comment, 393 460 }) 394 461 } 395 462 396 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 397 465 user := rp.oauth.GetUser(r) 398 466 f, err := rp.repoResolver.Resolve(r) 399 467 if err != nil { 400 - log.Println("failed to get repo and knot", err) 468 + l.Error("failed to get repo and knot", "err", err) 401 469 return 402 470 } 403 471 404 - issueId := chi.URLParam(r, "issue") 405 - issueIdInt, err := strconv.Atoi(issueId) 406 - if err != nil { 407 - http.Error(w, "bad issue id", http.StatusBadRequest) 408 - log.Println("failed to parse issue id", err) 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 409 476 return 410 477 } 411 478 412 - commentId := chi.URLParam(r, "comment_id") 413 - commentIdInt, err := strconv.Atoi(commentId) 414 - if err != nil { 415 - http.Error(w, "bad comment id", http.StatusBadRequest) 416 - log.Println("failed to parse issue id", err) 417 - return 418 - } 419 - 420 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 421 484 if err != nil { 422 - log.Println("failed to get issue", err) 423 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 424 487 return 425 488 } 426 - 427 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 428 - if err != nil { 429 - http.Error(w, "bad comment id", http.StatusBadRequest) 489 + if len(comments) != 1 { 490 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 491 + http.Error(w, "invalid comment id", http.StatusBadRequest) 430 492 return 431 493 } 494 + comment := comments[0] 432 495 433 - if comment.OwnerDid != user.Did { 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 434 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 435 499 return 436 500 } ··· 441 505 LoggedInUser: user, 442 506 RepoInfo: f.RepoInfo(user), 443 507 Issue: issue, 444 - Comment: comment, 508 + Comment: &comment, 445 509 }) 446 510 case http.MethodPost: 447 511 // extract form value ··· 452 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 453 517 return 454 518 } 455 - rkey := comment.Rkey 456 519 457 - // optimistic update 458 - edited := time.Now() 459 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 525 + 526 + _, err = db.AddIssueComment(rp.db, newComment) 460 527 if err != nil { 461 528 log.Println("failed to perferom update-description query", err) 462 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 464 531 } 465 532 466 533 // rkey is optional, it was introduced later 467 - if comment.Rkey != "" { 534 + if newComment.Rkey != "" { 468 535 // update the record on pds 469 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 470 537 if err != nil { 471 - // failed to get record 472 - log.Println(err, rkey) 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 473 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 474 540 return 475 541 } 476 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 477 - record, _ := data.UnmarshalJSON(value) 478 - 479 - repoAt := record["repo"].(string) 480 - issueAt := record["issue"].(string) 481 - createdAt := record["createdAt"].(string) 482 - commentIdInt64 := int64(commentIdInt) 483 542 484 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 485 544 Collection: tangled.RepoIssueCommentNSID, 486 545 Repo: user.Did, 487 - Rkey: rkey, 546 + Rkey: newComment.Rkey, 488 547 SwapRecord: ex.Cid, 489 548 Record: &lexutil.LexiconTypeDecoder{ 490 - Val: &tangled.RepoIssueComment{ 491 - Repo: &repoAt, 492 - Issue: issueAt, 493 - CommentId: &commentIdInt64, 494 - Owner: &comment.OwnerDid, 495 - Body: newBody, 496 - CreatedAt: createdAt, 497 - }, 549 + Val: &record, 498 550 }, 499 551 }) 500 552 if err != nil { 501 - log.Println(err) 553 + l.Error("failed to update record on PDS", "err", err) 502 554 } 503 555 } 504 - 505 - // optimistic update for htmx 506 - didHandleMap := map[string]string{ 507 - user.Did: user.Handle, 508 - } 509 - comment.Body = newBody 510 - comment.Edited = &edited 511 556 512 557 // return new comment body with htmx 513 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 514 559 LoggedInUser: user, 515 560 RepoInfo: f.RepoInfo(user), 516 - DidHandleMap: didHandleMap, 517 561 Issue: issue, 518 - Comment: comment, 562 + Comment: &newComment, 519 563 }) 564 + } 565 + } 566 + 567 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 568 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 569 + user := rp.oauth.GetUser(r) 570 + f, err := rp.repoResolver.Resolve(r) 571 + if err != nil { 572 + l.Error("failed to get repo and knot", "err", err) 520 573 return 574 + } 521 575 576 + issue, ok := r.Context().Value("issue").(*db.Issue) 577 + if !ok { 578 + l.Error("failed to get issue") 579 + rp.pages.Error404(w) 580 + return 522 581 } 523 582 583 + commentId := chi.URLParam(r, "commentId") 584 + comments, err := db.GetIssueComments( 585 + rp.db, 586 + db.FilterEq("id", commentId), 587 + ) 588 + if err != nil { 589 + l.Error("failed to fetch comment", "id", commentId) 590 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 591 + return 592 + } 593 + if len(comments) != 1 { 594 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 595 + http.Error(w, "invalid comment id", http.StatusBadRequest) 596 + return 597 + } 598 + comment := comments[0] 599 + 600 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 601 + LoggedInUser: user, 602 + RepoInfo: f.RepoInfo(user), 603 + Issue: issue, 604 + Comment: &comment, 605 + }) 524 606 } 525 607 526 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 527 610 user := rp.oauth.GetUser(r) 528 611 f, err := rp.repoResolver.Resolve(r) 529 612 if err != nil { 530 - log.Println("failed to get repo and knot", err) 613 + l.Error("failed to get repo and knot", "err", err) 614 + return 615 + } 616 + 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 531 621 return 532 622 } 533 623 534 - issueId := chi.URLParam(r, "issue") 535 - issueIdInt, err := strconv.Atoi(issueId) 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 536 629 if err != nil { 537 - http.Error(w, "bad issue id", http.StatusBadRequest) 538 - log.Println("failed to parse issue id", err) 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 632 + return 633 + } 634 + if len(comments) != 1 { 635 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 636 + http.Error(w, "invalid comment id", http.StatusBadRequest) 539 637 return 540 638 } 639 + comment := comments[0] 541 640 542 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 641 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 642 + LoggedInUser: user, 643 + RepoInfo: f.RepoInfo(user), 644 + Issue: issue, 645 + Comment: &comment, 646 + }) 647 + } 648 + 649 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 650 + l := rp.logger.With("handler", "DeleteIssueComment") 651 + user := rp.oauth.GetUser(r) 652 + f, err := rp.repoResolver.Resolve(r) 543 653 if err != nil { 544 - log.Println("failed to get issue", err) 545 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 654 + l.Error("failed to get repo and knot", "err", err) 546 655 return 547 656 } 548 657 549 - commentId := chi.URLParam(r, "comment_id") 550 - commentIdInt, err := strconv.Atoi(commentId) 551 - if err != nil { 552 - http.Error(w, "bad comment id", http.StatusBadRequest) 553 - log.Println("failed to parse issue id", err) 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 554 662 return 555 663 } 556 664 557 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 558 670 if err != nil { 559 - http.Error(w, "bad comment id", http.StatusBadRequest) 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 560 673 return 561 674 } 675 + if len(comments) != 1 { 676 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 677 + http.Error(w, "invalid comment id", http.StatusBadRequest) 678 + return 679 + } 680 + comment := comments[0] 562 681 563 - if comment.OwnerDid != user.Did { 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 564 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 685 return 566 686 } ··· 572 692 573 693 // optimistic deletion 574 694 deleted := time.Now() 575 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 576 696 if err != nil { 577 - log.Println("failed to delete comment") 697 + l.Error("failed to delete comment", "err", err) 578 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 579 699 return 580 700 } ··· 588 708 return 589 709 } 590 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 591 - Collection: tangled.GraphFollowNSID, 711 + Collection: tangled.RepoIssueCommentNSID, 592 712 Repo: user.Did, 593 713 Rkey: comment.Rkey, 594 714 }) ··· 598 718 } 599 719 600 720 // optimistic update for htmx 601 - didHandleMap := map[string]string{ 602 - user.Did: user.Handle, 603 - } 604 721 comment.Body = "" 605 722 comment.Deleted = &deleted 606 723 607 724 // htmx fragment of comment after deletion 608 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 609 726 LoggedInUser: user, 610 727 RepoInfo: f.RepoInfo(user), 611 - DidHandleMap: didHandleMap, 612 728 Issue: issue, 613 - Comment: comment, 729 + Comment: &comment, 614 730 }) 615 - return 616 731 } 617 732 618 733 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 641 756 return 642 757 } 643 758 644 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 759 + openVal := 0 760 + if isOpen { 761 + openVal = 1 762 + } 763 + issues, err := db.GetIssuesPaginated( 764 + rp.db, 765 + page, 766 + db.FilterEq("repo_at", f.RepoAt()), 767 + db.FilterEq("open", openVal), 768 + ) 645 769 if err != nil { 646 770 log.Println("failed to get issues", err) 647 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 648 772 return 649 773 } 650 774 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 775 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 666 776 LoggedInUser: rp.oauth.GetUser(r), 667 777 RepoInfo: f.RepoInfo(user), 668 778 Issues: issues, 669 - DidHandleMap: didHandleMap, 670 779 FilteringByOpen: isOpen, 671 780 Page: page, 672 781 }) 673 - return 674 782 } 675 783 676 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 677 786 user := rp.oauth.GetUser(r) 678 787 679 788 f, err := rp.repoResolver.Resolve(r) 680 789 if err != nil { 681 - log.Println("failed to get repo and knot", err) 790 + l.Error("failed to get repo and knot", "err", err) 682 791 return 683 792 } 684 793 ··· 689 798 RepoInfo: f.RepoInfo(user), 690 799 }) 691 800 case http.MethodPost: 692 - title := r.FormValue("title") 693 - body := r.FormValue("body") 694 - 695 - if title == "" || body == "" { 696 - rp.pages.Notice(w, "issues", "Title and body are required") 697 - return 801 + issue := &db.Issue{ 802 + RepoAt: f.RepoAt(), 803 + Rkey: tid.TID(), 804 + Title: r.FormValue("title"), 805 + Body: r.FormValue("body"), 806 + Did: user.Did, 807 + Created: time.Now(), 698 808 } 699 809 700 - tx, err := rp.db.BeginTx(r.Context(), nil) 701 - if err != nil { 702 - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 810 + if err := rp.validator.ValidateIssue(issue); err != nil { 811 + l.Error("validation error", "err", err) 812 + rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 703 813 return 704 814 } 705 815 706 - issue := &db.Issue{ 707 - RepoAt: f.RepoAt, 708 - Title: title, 709 - Body: body, 710 - OwnerDid: user.Did, 711 - } 712 - err = db.NewIssue(tx, issue) 713 - if err != nil { 714 - log.Println("failed to create issue", err) 715 - rp.pages.Notice(w, "issues", "Failed to create issue.") 716 - return 717 - } 816 + record := issue.AsRecord() 718 817 818 + // create an atproto record 719 819 client, err := rp.oauth.AuthorizedClient(r) 720 820 if err != nil { 721 - log.Println("failed to get authorized client", err) 821 + l.Error("failed to get authorized client", "err", err) 722 822 rp.pages.Notice(w, "issues", "Failed to create issue.") 723 823 return 724 824 } 725 - atUri := f.RepoAt.String() 726 825 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 826 Collection: tangled.RepoIssueNSID, 728 827 Repo: user.Did, 729 - Rkey: tid.TID(), 828 + Rkey: issue.Rkey, 730 829 Record: &lexutil.LexiconTypeDecoder{ 731 - Val: &tangled.RepoIssue{ 732 - Repo: atUri, 733 - Title: title, 734 - Body: &body, 735 - Owner: user.Did, 736 - IssueId: int64(issue.IssueId), 737 - }, 830 + Val: &record, 738 831 }, 739 832 }) 740 833 if err != nil { 741 - log.Println("failed to create issue", err) 834 + l.Error("failed to create issue", "err", err) 742 835 rp.pages.Notice(w, "issues", "Failed to create issue.") 743 836 return 744 837 } 838 + atUri := resp.Uri 745 839 746 - err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 840 + tx, err := rp.db.BeginTx(r.Context(), nil) 747 841 if err != nil { 748 - log.Println("failed to set issue at", err) 842 + rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 843 + return 844 + } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 848 + 849 + if errors.Is(err1, sql.ErrTxDone) { 850 + err1 = nil 851 + } 852 + 853 + if err := errors.Join(err1, err2); err != nil { 854 + l.Error("failed to rollback txn", "err", err) 855 + } 856 + } 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 860 + if err != nil { 861 + log.Println("failed to create issue", err) 749 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 863 return 751 864 } 752 865 753 - rp.notifier.NewIssue(r.Context(), issue) 866 + if err = tx.Commit(); err != nil { 867 + log.Println("failed to create issue", err) 868 + rp.pages.Notice(w, "issues", "Failed to create issue.") 869 + return 870 + } 754 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 874 + rp.notifier.NewIssue(r.Context(), issue) 755 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 756 876 return 757 877 } 758 878 } 879 + 880 + // this is used to rollback changes made to the PDS 881 + // 882 + // it is a no-op if the provided ATURI is empty 883 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 884 + if aturi == "" { 885 + return nil 886 + } 887 + 888 + parsed := syntax.ATURI(aturi) 889 + 890 + collection := parsed.Collection().String() 891 + repo := parsed.Authority().String() 892 + rkey := parsed.RecordKey().String() 893 + 894 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 895 + Collection: collection, 896 + Repo: repo, 897 + Rkey: rkey, 898 + }) 899 + return err 900 + }
+24 -10
appview/issues/router.go
··· 12 12 13 13 r.Route("/", func(r chi.Router) { 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue()) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 21 + r.Group(func(r chi.Router) { 22 + r.Use(middleware.AuthMiddleware(i.oauth)) 23 + r.Post("/comment", i.NewIssueComment) 24 + r.Route("/comment/{commentId}/", func(r chi.Router) { 25 + r.Get("/", i.IssueComment) 26 + r.Delete("/", i.DeleteIssueComment) 27 + r.Get("/edit", i.EditIssueComment) 28 + r.Post("/edit", i.EditIssueComment) 29 + r.Get("/reply", i.ReplyIssueComment) 30 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 + }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 + r.Post("/close", i.CloseIssue) 36 + r.Post("/reopen", i.ReopenIssue) 37 + }) 38 + }) 16 39 17 40 r.Group(func(r chi.Router) { 18 41 r.Use(middleware.AuthMiddleware(i.oauth)) 19 42 r.Get("/new", i.NewIssue) 20 43 r.Post("/new", i.NewIssue) 21 - r.Post("/{issue}/comment", i.NewIssueComment) 22 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 - r.Get("/", i.IssueComment) 24 - r.Delete("/", i.DeleteIssueComment) 25 - r.Get("/edit", i.EditIssueComment) 26 - r.Post("/edit", i.EditIssueComment) 27 - }) 28 - r.Post("/{issue}/close", i.CloseIssue) 29 - r.Post("/{issue}/reopen", i.ReopenIssue) 30 44 }) 31 45 }) 32 46
+415 -233
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" 9 6 "log/slog" 10 7 "net/http" 11 - "strings" 8 + "slices" 12 9 "time" 13 10 14 11 "github.com/go-chi/chi/v5" ··· 18 15 "tangled.sh/tangled.sh/core/appview/middleware" 19 16 "tangled.sh/tangled.sh/core/appview/oauth" 20 17 "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 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) 46 45 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 49 48 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 - }) 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) 60 52 61 53 return r 62 54 } 63 55 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 - 56 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 68 57 user := k.OAuth.GetUser(r) 69 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 58 + registrations, err := db.GetRegistrations( 59 + k.Db, 60 + db.FilterEq("did", user.Did), 61 + ) 70 62 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 63 + k.Logger.Error("failed to fetch knot registrations", "err", err) 64 + w.WriteHeader(http.StatusInternalServerError) 65 + return 72 66 } 73 67 74 68 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 71 }) 78 72 } 79 73 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 74 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 75 + l := k.Logger.With("handler", "dashboard") 83 76 84 77 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 78 + l = l.With("user", user.Did) 87 79 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 80 + domain := chi.URLParam(r, "domain") 90 81 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 82 return 94 83 } 95 84 l = l.With("domain", domain) 96 85 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 86 + registrations, err := db.GetRegistrations( 87 + k.Db, 88 + db.FilterEq("did", user.Did), 89 + db.FilterEq("domain", domain), 90 + ) 91 + if err != nil { 92 + l.Error("failed to get registrations", "err", err) 93 + http.Error(w, "Not found", http.StatusNotFound) 94 + return 100 95 } 96 + if len(registrations) != 1 { 97 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 98 + return 99 + } 100 + registration := registrations[0] 101 101 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 102 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 103 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 104 + l.Error("failed to get knot members", "err", err) 105 + http.Error(w, "Not found", http.StatusInternalServerError) 106 106 return 107 107 } 108 + slices.Sort(members) 108 109 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 110 + repos, err := db.GetRepos( 111 + k.Db, 112 + 0, 113 + db.FilterEq("knot", domain), 114 + ) 110 115 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 116 + l.Error("failed to get knot repos", "err", err) 117 + http.Error(w, "Not found", http.StatusInternalServerError) 113 118 return 114 119 } 115 120 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 121 + // organize repos by did 122 + repoMap := make(map[string][]db.Repo) 123 + for _, r := range repos { 124 + repoMap[r.Did] = append(repoMap[r.Did], r) 125 + } 126 + 127 + k.Pages.Knot(w, pages.KnotParams{ 128 + LoggedInUser: user, 129 + Registration: &registration, 130 + Members: members, 131 + Repos: repoMap, 132 + IsOwner: true, 121 133 }) 122 134 } 123 135 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") 136 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 127 137 user := k.OAuth.GetUser(r) 138 + l := k.Logger.With("handler", "register") 128 139 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 140 + noticeId := "register-error" 141 + defaultErr := "Failed to register knot. Try again later." 131 142 fail := func() { 132 143 k.Pages.Notice(w, noticeId, defaultErr) 133 144 } 134 145 135 - domain := chi.URLParam(r, "domain") 146 + domain := r.FormValue("domain") 136 147 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 148 + k.Pages.Notice(w, noticeId, "Incomplete form.") 138 149 return 139 150 } 140 151 l = l.With("domain", domain) 152 + l = l.With("user", user.Did) 141 153 142 - l.Info("checking domain") 154 + tx, err := k.Db.Begin() 155 + if err != nil { 156 + l.Error("failed to start transaction", "err", err) 157 + fail() 158 + return 159 + } 160 + defer func() { 161 + tx.Rollback() 162 + k.Enforcer.E.LoadPolicy() 163 + }() 143 164 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 165 + err = db.AddKnot(tx, domain, user.Did) 145 166 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 167 + l.Error("failed to insert", "err", err) 147 168 fail() 148 169 return 149 170 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 171 + 172 + err = k.Enforcer.AddKnot(domain) 173 + if err != nil { 174 + l.Error("failed to create knot", "err", err) 175 + fail() 153 176 return 154 177 } 155 178 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 179 + // create record on pds 180 + client, err := k.OAuth.AuthorizedClient(r) 157 181 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 182 + l.Error("failed to authorize client", "err", err) 159 183 fail() 160 184 return 161 185 } 162 186 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 187 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + var exCid *string 189 + if ex != nil { 190 + exCid = ex.Cid 191 + } 192 + 193 + // re-announce by registering under same rkey 194 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + Collection: tangled.KnotNSID, 196 + Repo: user.Did, 197 + Rkey: domain, 198 + Record: &lexutil.LexiconTypeDecoder{ 199 + Val: &tangled.Knot{ 200 + CreatedAt: time.Now().Format(time.RFC3339), 201 + }, 202 + }, 203 + SwapRecord: exCid, 204 + }) 205 + 164 206 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 207 + l.Error("failed to put record", "err", err) 166 208 fail() 167 209 return 168 210 } 169 211 170 - resp, err := client.Init(user.Did) 212 + err = tx.Commit() 171 213 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) 214 + l.Error("failed to commit transaction", "err", err) 215 + fail() 174 216 return 175 217 } 176 218 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) 219 + err = k.Enforcer.E.SavePolicy() 220 + if err != nil { 221 + l.Error("failed to update ACL", "err", err) 222 + k.Pages.HxRefresh(w) 180 223 return 181 224 } 182 225 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) 226 + // begin verification 227 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 228 + if err != nil { 229 + l.Error("verification failed", "err", err) 230 + k.Pages.HxRefresh(w) 186 231 return 187 232 } 188 233 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 234 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 192 235 if err != nil { 236 + l.Error("failed to mark verified", "err", err) 237 + k.Pages.HxRefresh(w) 193 238 return 194 239 } 195 240 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 241 + // add this knot to knotstream 242 + go k.Knotstream.AddSource( 243 + r.Context(), 244 + eventconsumer.NewKnotSource(domain), 245 + ) 198 246 199 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 - l.Error("signature mismatch", "bytes", signatureBytes) 202 - return 247 + // ok 248 + k.Pages.HxRefresh(w) 249 + } 250 + 251 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 252 + user := k.OAuth.GetUser(r) 253 + l := k.Logger.With("handler", "delete") 254 + 255 + noticeId := "operation-error" 256 + defaultErr := "Failed to delete knot. Try again later." 257 + fail := func() { 258 + k.Pages.Notice(w, noticeId, defaultErr) 203 259 } 204 260 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 206 - if err != nil { 207 - l.Error("failed to start tx", "err", err) 261 + domain := chi.URLParam(r, "domain") 262 + if domain == "" { 263 + l.Error("empty domain") 208 264 fail() 209 265 return 210 266 } 211 - defer func() { 212 - tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 217 - }() 218 267 219 - // mark as registered 220 - err = db.Register(tx, domain) 268 + // get record from db first 269 + registrations, err := db.GetRegistrations( 270 + k.Db, 271 + db.FilterEq("did", user.Did), 272 + db.FilterEq("domain", domain), 273 + ) 221 274 if err != nil { 222 - l.Error("failed to register domain", "err", err) 275 + l.Error("failed to get registration", "err", err) 276 + fail() 277 + return 278 + } 279 + if len(registrations) != 1 { 280 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 223 281 fail() 224 282 return 225 283 } 284 + registration := registrations[0] 226 285 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 286 + tx, err := k.Db.Begin() 229 287 if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 288 + l.Error("failed to start txn", "err", err) 231 289 fail() 232 290 return 233 291 } 292 + defer func() { 293 + tx.Rollback() 294 + k.Enforcer.E.LoadPolicy() 295 + }() 234 296 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 297 + err = db.DeleteKnot( 298 + tx, 299 + db.FilterEq("did", user.Did), 300 + db.FilterEq("domain", domain), 301 + ) 237 302 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 303 + l.Error("failed to delete registration", "err", err) 239 304 fail() 240 305 return 241 306 } 242 307 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 308 + // delete from enforcer if it was registered 309 + if registration.Registered != nil { 310 + err = k.Enforcer.RemoveKnot(domain) 311 + if err != nil { 312 + l.Error("failed to update ACL", "err", err) 313 + fail() 314 + return 315 + } 316 + } 317 + 318 + client, err := k.OAuth.AuthorizedClient(r) 245 319 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 320 + l.Error("failed to authorize client", "err", err) 247 321 fail() 248 322 return 249 323 } 250 324 325 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + Collection: tangled.KnotNSID, 327 + Repo: user.Did, 328 + Rkey: domain, 329 + }) 330 + if err != nil { 331 + // non-fatal 332 + l.Error("failed to delete record", "err", err) 333 + } 334 + 251 335 err = tx.Commit() 252 336 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 337 + l.Error("failed to delete knot", "err", err) 254 338 fail() 255 339 return 256 340 } 257 341 258 342 err = k.Enforcer.E.SavePolicy() 259 343 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 344 + l.Error("failed to update ACL", "err", err) 345 + k.Pages.HxRefresh(w) 262 346 return 263 347 } 264 348 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 349 + shouldRedirect := r.Header.Get("shouldRedirect") 350 + if shouldRedirect == "true" { 351 + k.Pages.HxRedirect(w, "/knots") 352 + return 353 + } 270 354 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 355 + w.Write([]byte{}) 274 356 } 275 357 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 358 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 359 + user := k.OAuth.GetUser(r) 360 + l := k.Logger.With("handler", "retry") 361 + 362 + noticeId := "operation-error" 363 + defaultErr := "Failed to verify knot. Try again later." 278 364 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 365 + k.Pages.Notice(w, noticeId, defaultErr) 280 366 } 281 367 282 368 domain := chi.URLParam(r, "domain") 283 369 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 370 + l.Error("empty domain") 371 + fail() 285 372 return 286 373 } 287 374 l = l.With("domain", domain) 375 + l = l.With("user", user.Did) 288 376 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) 377 + // get record from db first 378 + registrations, err := db.GetRegistrations( 379 + k.Db, 380 + db.FilterEq("did", user.Did), 381 + db.FilterEq("domain", domain), 382 + ) 294 383 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 384 + l.Error("failed to get registration", "err", err) 296 385 fail() 386 + return 297 387 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 388 + if len(registrations) != 1 { 389 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 390 + fail() 300 391 return 301 392 } 393 + registration := registrations[0] 302 394 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 395 + // begin verification 396 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 304 397 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 398 + l.Error("verification failed", "err", err) 399 + 400 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 401 + k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 402 + return 403 + } 404 + 405 + if e, ok := err.(*serververify.OwnerMismatch); ok { 406 + k.Pages.Notice(w, noticeId, e.Error()) 407 + return 408 + } 409 + 306 410 fail() 307 411 return 308 412 } 309 413 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 414 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 415 + if err != nil { 416 + l.Error("failed to mark verified", "err", err) 417 + k.Pages.Notice(w, noticeId, err.Error()) 418 + return 419 + } 420 + 421 + // if this knot requires upgrade, then emit a record too 422 + // 423 + // this is part of migrating from the old knot system to the new one 424 + if registration.NeedsUpgrade { 425 + // re-announce by registering under same rkey 426 + client, err := k.OAuth.AuthorizedClient(r) 313 427 if err != nil { 314 - l.Error("failed to get members list", "err", err) 428 + l.Error("failed to authorize client", "err", err) 315 429 fail() 316 430 return 317 431 } 318 - } 319 432 320 - repos, err := db.GetRepos( 321 - k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 325 - ) 326 - if err != nil { 327 - l.Error("failed to get repos list", "err", err) 328 - fail() 329 - return 330 - } 331 - // convert to map 332 - repoByMember := make(map[string][]db.Repo) 333 - for _, r := range repos { 334 - repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 - } 433 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + var exCid *string 435 + if ex != nil { 436 + exCid = ex.Cid 437 + } 336 438 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() 439 + // ignore the error here 440 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + Collection: tangled.KnotNSID, 442 + Repo: user.Did, 443 + Rkey: domain, 444 + Record: &lexutil.LexiconTypeDecoder{ 445 + Val: &tangled.Knot{ 446 + CreatedAt: time.Now().Format(time.RFC3339), 447 + }, 448 + }, 449 + SwapRecord: exCid, 450 + }) 451 + if err != nil { 452 + l.Error("non-fatal: failed to reannouce knot", "err", err) 349 453 } 350 454 } 351 455 352 - k.Pages.Knot(w, pages.KnotParams{ 353 - LoggedInUser: user, 354 - DidHandleMap: didHandleMap, 355 - Registration: reg, 356 - Members: members, 357 - Repos: repoByMember, 358 - IsOwner: true, 359 - }) 360 - } 456 + // add this knot to knotstream 457 + go k.Knotstream.AddSource( 458 + r.Context(), 459 + eventconsumer.NewKnotSource(domain), 460 + ) 361 461 362 - // list members of domain, requires auth and requires owner status 363 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 - l := k.Logger.With("handler", "members") 365 - 366 - domain := chi.URLParam(r, "domain") 367 - if domain == "" { 368 - http.Error(w, "malformed url", http.StatusBadRequest) 462 + shouldRefresh := r.Header.Get("shouldRefresh") 463 + if shouldRefresh == "true" { 464 + k.Pages.HxRefresh(w) 369 465 return 370 466 } 371 - l = l.With("domain", domain) 372 467 373 - // list all members for this domain 374 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 468 + // Get updated registration to show 469 + registrations, err = db.GetRegistrations( 470 + k.Db, 471 + db.FilterEq("did", user.Did), 472 + db.FilterEq("domain", domain), 473 + ) 375 474 if err != nil { 376 - w.Write([]byte("failed to fetch member list")) 475 + l.Error("failed to get registration", "err", err) 476 + fail() 377 477 return 378 478 } 479 + if len(registrations) != 1 { 480 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 481 + fail() 482 + return 483 + } 484 + updatedRegistration := registrations[0] 379 485 380 - w.Write([]byte(strings.Join(memberDids, "\n"))) 486 + w.Header().Set("HX-Reswap", "outerHTML") 487 + k.Pages.KnotListing(w, pages.KnotListingParams{ 488 + Registration: &updatedRegistration, 489 + }) 381 490 } 382 491 383 - // add member to domain, requires auth and requires invite access 384 492 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 385 - l := k.Logger.With("handler", "members") 493 + user := k.OAuth.GetUser(r) 494 + l := k.Logger.With("handler", "addMember") 386 495 387 496 domain := chi.URLParam(r, "domain") 388 497 if domain == "" { 389 - http.Error(w, "malformed url", http.StatusBadRequest) 498 + l.Error("empty domain") 499 + http.Error(w, "Not found", http.StatusNotFound) 390 500 return 391 501 } 392 502 l = l.With("domain", domain) 503 + l = l.With("user", user.Did) 393 504 394 - reg, err := db.RegistrationByDomain(k.Db, domain) 505 + registrations, err := db.GetRegistrations( 506 + k.Db, 507 + db.FilterEq("did", user.Did), 508 + db.FilterEq("domain", domain), 509 + db.FilterIsNot("registered", "null"), 510 + ) 395 511 if err != nil { 396 - l.Error("failed to get registration by domain", "err", err) 397 - http.Error(w, "malformed url", http.StatusBadRequest) 512 + l.Error("failed to get registration", "err", err) 513 + return 514 + } 515 + if len(registrations) != 1 { 516 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 398 517 return 399 518 } 519 + registration := registrations[0] 400 520 401 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 402 - l = l.With("notice-id", noticeId) 521 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 403 522 defaultErr := "Failed to add member. Try again later." 404 523 fail := func() { 405 524 k.Pages.Notice(w, noticeId, defaultErr) 406 525 } 407 526 408 - subjectIdentifier := r.FormValue("subject") 409 - if subjectIdentifier == "" { 410 - http.Error(w, "malformed form", http.StatusBadRequest) 527 + member := r.FormValue("member") 528 + if member == "" { 529 + l.Error("empty member") 530 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 411 531 return 412 532 } 413 - l = l.With("subjectIdentifier", subjectIdentifier) 533 + l = l.With("member", member) 414 534 415 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 535 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 416 536 if err != nil { 417 - l.Error("failed to resolve identity", "err", err) 537 + l.Error("failed to resolve member identity to handle", "err", err) 418 538 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 419 539 return 420 540 } 421 - l = l.With("subjectDid", subjectIdentity.DID) 422 - 423 - l.Info("adding member to knot") 541 + if memberId.Handle.IsInvalidHandle() { 542 + l.Error("failed to resolve member identity to handle") 543 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 544 + return 545 + } 424 546 425 - // announce this relation into the firehose, store into owners' pds 547 + // write to pds 426 548 client, err := k.OAuth.AuthorizedClient(r) 427 549 if err != nil { 428 - l.Error("failed to create client", "err", err) 550 + l.Error("failed to authorize client", "err", err) 429 551 fail() 430 552 return 431 553 } 432 554 433 - currentUser := k.OAuth.GetUser(r) 434 - createdAt := time.Now().Format(time.RFC3339) 435 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 555 + rkey := tid.TID() 556 + 557 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 558 Collection: tangled.KnotMemberNSID, 437 - Repo: currentUser.Did, 438 - Rkey: tid.TID(), 559 + Repo: user.Did, 560 + Rkey: rkey, 439 561 Record: &lexutil.LexiconTypeDecoder{ 440 562 Val: &tangled.KnotMember{ 441 - Subject: subjectIdentity.DID.String(), 563 + CreatedAt: time.Now().Format(time.RFC3339), 442 564 Domain: domain, 443 - CreatedAt: createdAt, 444 - }}, 565 + Subject: memberId.DID.String(), 566 + }, 567 + }, 445 568 }) 446 - // invalid record 447 569 if err != nil { 448 - l.Error("failed to write to PDS", "err", err) 449 - fail() 570 + l.Error("failed to add record to PDS", "err", err) 571 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 450 572 return 451 573 } 452 - l = l.With("at-uri", resp.Uri) 453 - l.Info("wrote record to PDS") 454 574 455 - secret, err := db.GetRegistrationKey(k.Db, domain) 575 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 456 576 if err != nil { 457 - l.Error("failed to get registration key", "err", err) 577 + l.Error("failed to add member to ACLs", "err", err) 458 578 fail() 459 579 return 460 580 } 461 581 462 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 582 + err = k.Enforcer.E.SavePolicy() 463 583 if err != nil { 464 - l.Error("failed to create client", "err", err) 584 + l.Error("failed to save ACL policy", "err", err) 465 585 fail() 466 586 return 467 587 } 468 588 469 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 589 + // success 590 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 591 + } 592 + 593 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 594 + user := k.OAuth.GetUser(r) 595 + l := k.Logger.With("handler", "removeMember") 596 + 597 + noticeId := "operation-error" 598 + defaultErr := "Failed to remove member. Try again later." 599 + fail := func() { 600 + k.Pages.Notice(w, noticeId, defaultErr) 601 + } 602 + 603 + domain := chi.URLParam(r, "domain") 604 + if domain == "" { 605 + l.Error("empty domain") 606 + fail() 607 + return 608 + } 609 + l = l.With("domain", domain) 610 + l = l.With("user", user.Did) 611 + 612 + registrations, err := db.GetRegistrations( 613 + k.Db, 614 + db.FilterEq("did", user.Did), 615 + db.FilterEq("domain", domain), 616 + db.FilterIsNot("registered", "null"), 617 + ) 470 618 if err != nil { 471 - l.Error("failed to reach knotserver", "err", err) 472 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 619 + l.Error("failed to get registration", "err", err) 620 + return 621 + } 622 + if len(registrations) != 1 { 623 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 624 + return 625 + } 626 + 627 + member := r.FormValue("member") 628 + if member == "" { 629 + l.Error("empty member") 630 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 631 + return 632 + } 633 + l = l.With("member", member) 634 + 635 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 636 + if err != nil { 637 + l.Error("failed to resolve member identity to handle", "err", err) 638 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 639 + return 640 + } 641 + if memberId.Handle.IsInvalidHandle() { 642 + l.Error("failed to resolve member identity to handle") 643 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 473 644 return 474 645 } 475 646 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)) 647 + // remove from enforcer 648 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 649 + if err != nil { 650 + l.Error("failed to update ACLs", "err", err) 651 + fail() 479 652 return 480 653 } 481 654 482 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 655 + client, err := k.OAuth.AuthorizedClient(r) 483 656 if err != nil { 484 - l.Error("failed to add member to enforcer", "err", err) 657 + l.Error("failed to authorize client", "err", err) 485 658 fail() 486 659 return 487 660 } 488 661 489 - // success 490 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 491 - } 662 + // TODO: We need to track the rkey for knot members to delete the record 663 + // For now, just remove from ACLs 664 + _ = client 492 665 493 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 666 + // commit everything 667 + err = k.Enforcer.E.SavePolicy() 668 + if err != nil { 669 + l.Error("failed to save ACLs", "err", err) 670 + fail() 671 + return 672 + } 673 + 674 + // ok 675 + k.Pages.HxRefresh(w) 494 676 }
+57 -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 ··· 272 275 } 273 276 } 274 277 278 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 + func (mw Middleware) ResolveIssue() middlewareFunc { 280 + return func(next http.Handler) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 + f, err := mw.repoResolver.Resolve(r) 283 + if err != nil { 284 + log.Println("failed to fully resolve repo", err) 285 + mw.pages.ErrorKnot404(w) 286 + return 287 + } 288 + 289 + issueIdStr := chi.URLParam(r, "issue") 290 + issueId, err := strconv.Atoi(issueIdStr) 291 + if err != nil { 292 + log.Println("failed to fully resolve issue ID", err) 293 + mw.pages.ErrorKnot404(w) 294 + return 295 + } 296 + 297 + issues, err := db.GetIssues( 298 + mw.db, 299 + db.FilterEq("repo_at", f.RepoAt()), 300 + db.FilterEq("issue_id", issueId), 301 + ) 302 + if err != nil { 303 + log.Println("failed to get issues", "err", err) 304 + return 305 + } 306 + if len(issues) != 1 { 307 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 + return 309 + } 310 + issue := issues[0] 311 + 312 + ctx := context.WithValue(r.Context(), "issue", &issue) 313 + next.ServeHTTP(w, r.WithContext(ctx)) 314 + }) 315 + } 316 + } 317 + 275 318 // this should serve the go-import meta tag even if the path is technically 276 319 // a 404 like tangled.sh/oppi.li/go-git/v5 277 320 func (mw Middleware) GoImport() middlewareFunc { ··· 280 323 f, err := mw.repoResolver.Resolve(r) 281 324 if err != nil { 282 325 log.Println("failed to fully resolve repo", err) 283 - http.Error(w, "invalid repo url", http.StatusNotFound) 326 + mw.pages.ErrorKnot404(w) 284 327 return 285 328 } 286 329 287 - fullName := f.OwnerHandle() + "/" + f.RepoName 330 + fullName := f.OwnerHandle() + "/" + f.Name 288 331 289 332 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 290 333 if r.URL.Query().Get("go-get") == "1" {
+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
··· 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
··· 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 + }
+45 -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 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 35 + "resolve": func(s string) string { 36 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 37 + 38 + if err != nil { 39 + return s 40 + } 41 + 42 + if identity.Handle.IsInvalidHandle() { 43 + return "handle.invalid" 44 + } 45 + 46 + return "@" + identity.Handle.String() 47 + }, 31 48 "truncateAt30": func(s string) string { 32 49 if len(s) <= 30 { 33 50 return s ··· 74 91 "negf64": func(a float64) float64 { 75 92 return -a 76 93 }, 77 - "cond": func(cond interface{}, a, b string) string { 94 + "cond": func(cond any, a, b string) string { 78 95 if cond == nil { 79 96 return b 80 97 } ··· 167 184 return html.UnescapeString(s) 168 185 }, 169 186 "nl2br": func(text string) template.HTML { 170 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 187 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 171 188 }, 172 189 "unwrapText": func(text string) string { 173 190 paragraphs := strings.Split(text, "\n\n") ··· 193 210 } 194 211 return v.Slice(0, min(n, v.Len())).Interface() 195 212 }, 196 - 197 213 "markdown": func(text string) template.HTML { 198 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 199 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 214 + p.rctx.RendererType = markup.RendererTypeDefault 215 + htmlString := p.rctx.RenderMarkdown(text) 216 + sanitized := p.rctx.SanitizeDefault(htmlString) 217 + return template.HTML(sanitized) 218 + }, 219 + "description": func(text string) template.HTML { 220 + p.rctx.RendererType = markup.RendererTypeDefault 221 + htmlString := p.rctx.RenderMarkdown(text) 222 + sanitized := p.rctx.SanitizeDescription(htmlString) 223 + return template.HTML(sanitized) 200 224 }, 201 225 "isNil": func(t any) bool { 202 226 // returns false for other "zero" values ··· 236 260 }, 237 261 "cssContentHash": CssContentHash, 238 262 "fileTree": filetree.FileTree, 263 + "pathEscape": func(s string) string { 264 + return url.PathEscape(s) 265 + }, 239 266 "pathUnescape": func(s string) string { 240 267 u, _ := url.PathUnescape(s) 241 268 return u ··· 253 280 }, 254 281 "layoutCenter": func() string { 255 282 return "col-span-1 md:col-span-8 lg:col-span-6" 283 + }, 284 + 285 + "normalizeForHtmlId": func(s string) string { 286 + // TODO: extend this to handle other cases? 287 + return strings.ReplaceAll(s, ":", "_") 288 + }, 289 + "sshFingerprint": func(pubKey string) string { 290 + fp, err := crypto.SSHFingerprint(pubKey) 291 + if err != nil { 292 + return "error" 293 + } 294 + return fp 256 295 }, 257 296 } 258 297 }
+12
appview/pages/markup/format.go
··· 13 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 14 } 15 15 16 + // ReadmeFilenames contains the list of common README filenames to search for, 17 + // in order of preference. Only includes well-supported formats. 18 + var ReadmeFilenames = []string{ 19 + "README.md", "readme.md", 20 + "README", 21 + "readme", 22 + "README.markdown", 23 + "readme.markdown", 24 + "README.txt", 25 + "readme.txt", 26 + } 27 + 16 28 func GetFormat(filename string) Format { 17 29 for format, extensions := range FileTypes { 18 30 for _, extension := range extensions {
+73 -39
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" ··· 19 22 "github.com/yuin/goldmark/util" 20 23 htmlparse "golang.org/x/net/html" 21 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 22 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 27 ) 24 28 ··· 40 44 repoinfo.RepoInfo 41 45 IsDev bool 42 46 RendererType RendererType 47 + Sanitizer Sanitizer 43 48 } 44 49 45 50 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 51 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 52 + goldmark.WithExtensions( 53 + extension.GFM, 54 + highlighting.NewHighlighting( 55 + highlighting.WithFormatOptions( 56 + chromahtml.Standalone(false), 57 + chromahtml.WithClasses(true), 58 + ), 59 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 60 + ), 61 + extension.NewFootnote( 62 + extension.WithFootnoteIDPrefix([]byte("footnote")), 63 + ), 64 + treeblood.MathML(), 65 + ), 48 66 goldmark.WithParserOptions( 49 67 parser.WithAutoHeadingID(), 50 68 ), ··· 145 163 } 146 164 } 147 165 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 166 + func (rctx *RenderContext) SanitizeDefault(html string) string { 167 + return rctx.Sanitizer.SanitizeDefault(html) 168 + } 150 169 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") 159 - 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) 170 + func (rctx *RenderContext) SanitizeDescription(html string) string { 171 + return rctx.Sanitizer.SanitizeDescription(html) 177 172 } 178 173 179 174 type MarkdownTransformer struct { ··· 189 184 switch a.rctx.RendererType { 190 185 case RendererTypeRepoMarkdown: 191 186 switch n := n.(type) { 187 + case *ast.Heading: 188 + a.rctx.anchorHeadingTransformer(n) 192 189 case *ast.Link: 193 190 a.rctx.relativeLinkTransformer(n) 194 191 case *ast.Image: ··· 197 194 } 198 195 case RendererTypeDefault: 199 196 switch n := n.(type) { 197 + case *ast.Heading: 198 + a.rctx.anchorHeadingTransformer(n) 200 199 case *ast.Image: 201 200 a.rctx.imageFromKnotAstTransformer(n) 202 201 a.rctx.camoImageLinkAstTransformer(n) ··· 211 210 212 211 dst := string(link.Destination) 213 212 214 - if isAbsoluteUrl(dst) { 213 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 214 return 216 215 } 217 216 ··· 233 232 234 233 actualPath := rctx.actualPath(dst) 235 234 235 + repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 236 + 237 + query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 238 + repoName, url.PathEscape(rctx.RepoInfo.Ref), actualPath) 239 + 236 240 parsedURL := &url.URL{ 237 - Scheme: scheme, 238 - Host: rctx.Knot, 239 - Path: path.Join("/", 240 - rctx.RepoInfo.OwnerDid, 241 - rctx.RepoInfo.Name, 242 - "raw", 243 - url.PathEscape(rctx.RepoInfo.Ref), 244 - actualPath), 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 245 245 } 246 246 newPath := parsedURL.String() 247 247 return newPath ··· 252 252 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 253 } 254 254 255 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 256 + idGeneric, exists := h.AttributeString("id") 257 + if !exists { 258 + return // no id, nothing to do 259 + } 260 + id, ok := idGeneric.([]byte) 261 + if !ok { 262 + return 263 + } 264 + 265 + // create anchor link 266 + anchor := ast.NewLink() 267 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 268 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 269 + 270 + // create icon text 271 + iconText := ast.NewString([]byte("#")) 272 + anchor.AppendChild(anchor, iconText) 273 + 274 + // set class on heading 275 + h.SetAttribute([]byte("class"), []byte("heading")) 276 + 277 + // append anchor to heading 278 + h.AppendChild(h, anchor) 279 + } 280 + 255 281 // actualPath decides when to join the file path with the 256 282 // current repository directory (essentially only when the link 257 283 // destination is relative. if it's absolute then we assume the ··· 271 297 } 272 298 return parsed.IsAbs() 273 299 } 300 + 301 + func isFragment(link string) bool { 302 + return strings.HasPrefix(link, "#") 303 + } 304 + 305 + func isMail(link string) bool { 306 + return strings.HasPrefix(link, "mailto:") 307 + }
+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 + }
+381 -252
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) 80 - var fragmentPaths []string 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 81 89 82 - // Use embedded FS for initial loading 83 - // First, collect all fragment paths 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) { 96 + var fragmentPaths []string 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 { 116 - 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 132 - } 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...) 143 - if err != nil { 144 - return fmt.Errorf("setting up template: %w", err) 145 - } 146 - templates[name] = tmpl 147 - log.Printf("loaded template: %s", name) 148 - return nil 149 - }) 117 + return fragmentPaths, nil 118 + } 119 + 120 + // parse without memoization 121 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 122 + paths, err := p.fragmentPaths() 123 + if err != nil { 124 + return nil, err 125 + } 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 128 + } 129 + 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 150 135 if err != nil { 151 - log.Fatalf("walking template dir: %v", err) 136 + return nil, err 152 137 } 153 138 154 - log.Printf("total templates loaded: %d", len(templates)) 155 - p.mu.Lock() 156 - defer p.mu.Unlock() 157 - p.t = templates 139 + return parsed, nil 158 140 } 159 141 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 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 144 + 145 + // never cache in dev mode 146 + if cached, exists := p.cache.Get(key); !p.dev && exists { 147 + return cached, nil 164 148 } 165 149 166 - log.Printf("reloading template from disk: %s", name) 150 + result, err := p.rawParse(stack...) 151 + if err != nil { 152 + return nil, err 153 + } 167 154 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 - }) 186 - if err != nil { 187 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 158 + 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 188 163 } 164 + return p.parse(stack...) 165 + } 189 166 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) 167 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 168 + stack := []string{ 169 + "layouts/base", 170 + "layouts/repobase", 171 + top, 194 172 } 173 + return p.parse(stack...) 174 + } 195 175 196 - // Create a new template 197 - tmpl := template.New(name).Funcs(p.funcMap()) 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 181 + } 182 + return p.parse(stack...) 183 + } 198 184 199 - // Parse layouts 200 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 201 - layouts, err := filepath.Glob(layoutGlob) 185 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 186 + tpl, err := p.parse(name) 202 187 if err != nil { 203 - return fmt.Errorf("finding layout templates: %w", err) 188 + return err 204 189 } 205 190 206 - // Create paths for parsing 207 - allFiles := append(layouts, fragmentPaths...) 208 - allFiles = append(allFiles, templatePath) 191 + return tpl.Execute(w, params) 192 + } 209 193 210 - // Parse all templates 211 - tmpl, err = tmpl.ParseFiles(allFiles...) 194 + func (p *Pages) execute(name string, w io.Writer, params any) error { 195 + tpl, err := p.parseBase(name) 212 196 if err != nil { 213 - return fmt.Errorf("parsing template files: %w", err) 197 + return err 214 198 } 215 199 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 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 222 201 } 223 202 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 - } 203 + func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 204 + tpl, err := p.parseRepoBase(name) 205 + if err != nil { 206 + return err 231 207 } 232 208 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) 238 - } 209 + return tpl.ExecuteTemplate(w, "layouts/base", params) 210 + } 239 211 240 - if base == "" { 241 - return tmpl.Execute(w, params) 242 - } else { 243 - return tmpl.ExecuteTemplate(w, base, params) 212 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 213 + tpl, err := p.parseProfileBase(name) 214 + if err != nil { 215 + return err 244 216 } 245 - } 246 217 247 - func (p *Pages) execute(name string, w io.Writer, params any) error { 248 - return p.executeOrReload(name, w, "layouts/base", params) 249 - } 250 - 251 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "", params) 218 + return tpl.ExecuteTemplate(w, "layouts/base", params) 253 219 } 254 220 255 - func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "layouts/repobase", params) 221 + func (p *Pages) Favicon(w io.Writer) error { 222 + return p.executePlain("favicon", w, nil) 257 223 } 258 224 259 225 type LoginParams struct { 226 + ReturnUrl string 260 227 } 261 228 262 229 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 273 240 274 241 type TermsOfServiceParams struct { 275 242 LoggedInUser *oauth.User 243 + Content template.HTML 276 244 } 277 245 278 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 + filename := "terms.md" 248 + filePath := filepath.Join("legal", filename) 249 + markdownBytes, err := os.ReadFile(filePath) 250 + if err != nil { 251 + return fmt.Errorf("failed to read %s: %w", filename, err) 252 + } 253 + 254 + p.rctx.RendererType = markup.RendererTypeDefault 255 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 256 + sanitized := p.rctx.SanitizeDefault(htmlString) 257 + params.Content = template.HTML(sanitized) 258 + 279 259 return p.execute("legal/terms", w, params) 280 260 } 281 261 282 262 type PrivacyPolicyParams struct { 283 263 LoggedInUser *oauth.User 264 + Content template.HTML 284 265 } 285 266 286 267 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 + filename := "privacy.md" 269 + filePath := filepath.Join("legal", filename) 270 + markdownBytes, err := os.ReadFile(filePath) 271 + if err != nil { 272 + return fmt.Errorf("failed to read %s: %w", filename, err) 273 + } 274 + 275 + p.rctx.RendererType = markup.RendererTypeDefault 276 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 277 + sanitized := p.rctx.SanitizeDefault(htmlString) 278 + params.Content = template.HTML(sanitized) 279 + 287 280 return p.execute("legal/privacy", w, params) 288 281 } 289 282 290 283 type TimelineParams struct { 291 284 LoggedInUser *oauth.User 292 285 Timeline []db.TimelineEvent 293 - DidHandleMap map[string]string 286 + Repos []db.Repo 294 287 } 295 288 296 289 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 297 - return p.execute("timeline", w, params) 290 + return p.execute("timeline/timeline", w, params) 291 + } 292 + 293 + type UserProfileSettingsParams struct { 294 + LoggedInUser *oauth.User 295 + Tabs []map[string]any 296 + Tab string 298 297 } 299 298 300 - type SettingsParams struct { 299 + func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 300 + return p.execute("user/settings/profile", w, params) 301 + } 302 + 303 + type UserKeysSettingsParams struct { 301 304 LoggedInUser *oauth.User 302 305 PubKeys []db.PublicKey 306 + Tabs []map[string]any 307 + Tab string 308 + } 309 + 310 + func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 311 + return p.execute("user/settings/keys", w, params) 312 + } 313 + 314 + type UserEmailsSettingsParams struct { 315 + LoggedInUser *oauth.User 303 316 Emails []db.Email 317 + Tabs []map[string]any 318 + Tab string 319 + } 320 + 321 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 322 + return p.execute("user/settings/emails", w, params) 323 + } 324 + 325 + type UpgradeBannerParams struct { 326 + Registrations []db.Registration 327 + Spindles []db.Spindle 304 328 } 305 329 306 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 307 - return p.execute("settings", w, params) 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 308 332 } 309 333 310 334 type KnotsParams struct { ··· 318 342 319 343 type KnotParams struct { 320 344 LoggedInUser *oauth.User 321 - DidHandleMap map[string]string 322 345 Registration *db.Registration 323 346 Members []string 324 347 Repos map[string][]db.Repo ··· 330 353 } 331 354 332 355 type KnotListingParams struct { 333 - db.Registration 356 + *db.Registration 334 357 } 335 358 336 359 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 337 360 return p.executePlain("knots/fragments/knotListing", w, params) 338 361 } 339 362 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 363 type SpindlesParams struct { 357 364 LoggedInUser *oauth.User 358 365 Spindles []db.Spindle ··· 375 382 Spindle db.Spindle 376 383 Members []string 377 384 Repos map[string][]db.Repo 378 - DidHandleMap map[string]string 379 385 } 380 386 381 387 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 401 407 return p.execute("repo/fork", w, params) 402 408 } 403 409 404 - type ProfilePageParams struct { 410 + type ProfileCard struct { 411 + UserDid string 412 + UserHandle string 413 + FollowStatus db.FollowStatus 414 + Punchcard *db.Punchcard 415 + Profile *db.Profile 416 + Stats ProfileStats 417 + Active string 418 + } 419 + 420 + type ProfileStats struct { 421 + RepoCount int64 422 + StarredCount int64 423 + StringCount int64 424 + FollowersCount int64 425 + FollowingCount int64 426 + } 427 + 428 + func (p *ProfileCard) GetTabs() [][]any { 429 + tabs := [][]any{ 430 + {"overview", "overview", "square-chart-gantt", nil}, 431 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 432 + {"starred", "starred", "star", p.Stats.StarredCount}, 433 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 434 + } 435 + 436 + return tabs 437 + } 438 + 439 + type ProfileOverviewParams struct { 405 440 LoggedInUser *oauth.User 406 441 Repos []db.Repo 407 442 CollaboratingRepos []db.Repo 408 443 ProfileTimeline *db.ProfileTimeline 409 - Card ProfileCard 410 - Punchcard db.Punchcard 444 + Card *ProfileCard 445 + Active string 446 + } 411 447 412 - DidHandleMap map[string]string 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 413 451 } 414 452 415 - type ProfileCard struct { 416 - UserDid string 417 - UserHandle string 418 - FollowStatus db.FollowStatus 419 - Followers int 420 - Following int 421 - 422 - Profile *db.Profile 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 423 458 } 424 459 425 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 426 - return p.execute("user/profile", w, params) 460 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 461 + params.Active = "repos" 462 + return p.executeProfile("user/repos", w, params) 427 463 } 428 464 429 - type ReposPageParams struct { 465 + type ProfileStarredParams struct { 430 466 LoggedInUser *oauth.User 431 467 Repos []db.Repo 432 - Card ProfileCard 468 + Card *ProfileCard 469 + Active string 470 + } 433 471 434 - DidHandleMap map[string]string 472 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 473 + params.Active = "starred" 474 + return p.executeProfile("user/starred", w, params) 475 + } 476 + 477 + type ProfileStringsParams struct { 478 + LoggedInUser *oauth.User 479 + Strings []db.String 480 + Card *ProfileCard 481 + Active string 482 + } 483 + 484 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 485 + params.Active = "strings" 486 + return p.executeProfile("user/strings", w, params) 487 + } 488 + 489 + type FollowCard struct { 490 + UserDid string 491 + FollowStatus db.FollowStatus 492 + FollowersCount int64 493 + FollowingCount int64 494 + Profile *db.Profile 495 + } 496 + 497 + type ProfileFollowersParams struct { 498 + LoggedInUser *oauth.User 499 + Followers []FollowCard 500 + Card *ProfileCard 501 + Active string 502 + } 503 + 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 507 + } 508 + 509 + type ProfileFollowingParams struct { 510 + LoggedInUser *oauth.User 511 + Following []FollowCard 512 + Card *ProfileCard 513 + Active string 435 514 } 436 515 437 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 438 - return p.execute("user/repos", w, params) 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 439 519 } 440 520 441 521 type FollowFragmentParams struct { ··· 460 540 LoggedInUser *oauth.User 461 541 Profile *db.Profile 462 542 AllRepos []PinnedRepo 463 - DidHandleMap map[string]string 464 543 } 465 544 466 545 type PinnedRepo struct { ··· 495 574 } 496 575 497 576 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 577 + LoggedInUser *oauth.User 578 + RepoInfo repoinfo.RepoInfo 579 + Active string 580 + TagMap map[string][]string 581 + CommitsTrunc []*object.Commit 582 + TagsTrunc []*types.TagReference 583 + BranchesTrunc []types.Branch 584 + // ForkInfo *types.ForkInfo 506 585 HTMLReadme template.HTML 507 586 Raw bool 508 587 EmailToDidOrHandle map[string]string 509 588 VerifiedCommits commitverify.VerifiedCommits 510 589 Languages []types.RepoLanguageDetails 511 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 512 592 types.RepoIndexResponse 513 593 } 514 594 ··· 518 598 return p.executeRepo("repo/empty", w, params) 519 599 } 520 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 521 605 p.rctx.RepoInfo = params.RepoInfo 606 + p.rctx.RepoInfo.Ref = params.Ref 522 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 523 608 524 609 if params.ReadmeFileName != "" { 525 - var htmlString string 526 610 ext := filepath.Ext(params.ReadmeFileName) 527 611 switch ext { 528 612 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 529 - htmlString = p.rctx.Sanitize(htmlString) 530 - htmlString = p.rctx.RenderMarkdown(params.Readme) 531 613 params.Raw = false 532 - params.HTMLReadme = template.HTML(htmlString) 614 + htmlString := p.rctx.RenderMarkdown(params.Readme) 615 + sanitized := p.rctx.SanitizeDefault(htmlString) 616 + params.HTMLReadme = template.HTML(sanitized) 533 617 default: 534 618 params.Raw = true 535 619 } ··· 605 689 606 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 607 691 params.Active = "overview" 608 - return p.execute("repo/tree", w, params) 692 + return p.executeRepo("repo/tree", w, params) 609 693 } 610 694 611 695 type RepoBranchesParams struct { ··· 656 740 ShowRendered bool 657 741 RenderToggle bool 658 742 RenderedContents template.HTML 659 - types.RepoBlobResponse 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 660 749 } 661 750 662 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 668 757 p.rctx.RepoInfo = params.RepoInfo 669 758 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 670 759 htmlString := p.rctx.RenderMarkdown(params.Contents) 671 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 760 + sanitized := p.rctx.SanitizeDefault(htmlString) 761 + params.RenderedContents = template.HTML(sanitized) 672 762 } 673 763 } 674 764 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 - ) 765 + c := params.Contents 766 + formatter := chromahtml.New( 767 + chromahtml.InlineCode(false), 768 + chromahtml.WithLineNumbers(true), 769 + chromahtml.WithLinkableLineNumbers(true, "L"), 770 + chromahtml.Standalone(false), 771 + chromahtml.WithClasses(true), 772 + ) 684 773 685 - lexer := lexers.Get(filepath.Base(params.Path)) 686 - if lexer == nil { 687 - lexer = lexers.Fallback 688 - } 774 + lexer := lexers.Get(filepath.Base(params.Path)) 775 + if lexer == nil { 776 + lexer = lexers.Fallback 777 + } 689 778 690 - iterator, err := lexer.Tokenise(nil, c) 691 - if err != nil { 692 - return fmt.Errorf("chroma tokenize: %w", err) 693 - } 779 + iterator, err := lexer.Tokenise(nil, c) 780 + if err != nil { 781 + return fmt.Errorf("chroma tokenize: %w", err) 782 + } 694 783 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 - } 700 - 701 - params.Contents = code.String() 784 + var code bytes.Buffer 785 + err = formatter.Format(&code, style, iterator) 786 + if err != nil { 787 + return fmt.Errorf("chroma format: %w", err) 702 788 } 703 789 790 + params.Contents = code.String() 704 791 params.Active = "overview" 705 792 return p.executeRepo("repo/blob", w, params) 706 793 } ··· 779 866 RepoInfo repoinfo.RepoInfo 780 867 Active string 781 868 Issues []db.Issue 782 - DidHandleMap map[string]string 783 869 Page pagination.Page 784 870 FilteringByOpen bool 785 871 } ··· 793 879 LoggedInUser *oauth.User 794 880 RepoInfo repoinfo.RepoInfo 795 881 Active string 796 - Issue db.Issue 797 - Comments []db.Comment 882 + Issue *db.Issue 883 + CommentList []db.CommentListItem 798 884 IssueOwnerHandle string 799 - DidHandleMap map[string]string 800 885 801 886 OrderedReactionKinds []db.ReactionKind 802 887 Reactions map[db.ReactionKind]int 803 888 UserReacted map[db.ReactionKind]bool 889 + } 890 + 891 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 892 + params.Active = "issues" 893 + return p.executeRepo("repo/issues/issue", w, params) 894 + } 804 895 805 - State string 896 + type EditIssueParams struct { 897 + LoggedInUser *oauth.User 898 + RepoInfo repoinfo.RepoInfo 899 + Issue *db.Issue 900 + Action string 901 + } 902 + 903 + func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 904 + params.Action = "edit" 905 + return p.executePlain("repo/issues/fragments/putIssue", w, params) 806 906 } 807 907 808 908 type ThreadReactionFragmentParams struct { ··· 816 916 return p.executePlain("repo/fragments/reaction", w, params) 817 917 } 818 918 819 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 820 - params.Active = "issues" 821 - if params.Issue.Open { 822 - params.State = "open" 823 - } else { 824 - params.State = "closed" 825 - } 826 - return p.execute("repo/issues/issue", w, params) 827 - } 828 - 829 919 type RepoNewIssueParams struct { 830 920 LoggedInUser *oauth.User 831 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 832 923 Active string 924 + Action string 833 925 } 834 926 835 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 836 928 params.Active = "issues" 929 + params.Action = "create" 837 930 return p.executeRepo("repo/issues/new", w, params) 838 931 } 839 932 ··· 841 934 LoggedInUser *oauth.User 842 935 RepoInfo repoinfo.RepoInfo 843 936 Issue *db.Issue 844 - Comment *db.Comment 937 + Comment *db.IssueComment 845 938 } 846 939 847 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 848 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 849 942 } 850 943 851 - type SingleIssueCommentParams struct { 944 + type ReplyIssueCommentPlaceholderParams struct { 852 945 LoggedInUser *oauth.User 853 - DidHandleMap map[string]string 946 + RepoInfo repoinfo.RepoInfo 947 + Issue *db.Issue 948 + Comment *db.IssueComment 949 + } 950 + 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 952 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 953 + } 954 + 955 + type ReplyIssueCommentParams struct { 956 + LoggedInUser *oauth.User 957 + RepoInfo repoinfo.RepoInfo 958 + Issue *db.Issue 959 + Comment *db.IssueComment 960 + } 961 + 962 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 963 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 964 + } 965 + 966 + type IssueCommentBodyParams struct { 967 + LoggedInUser *oauth.User 854 968 RepoInfo repoinfo.RepoInfo 855 969 Issue *db.Issue 856 - Comment *db.Comment 970 + Comment *db.IssueComment 857 971 } 858 972 859 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 860 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 973 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 974 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 861 975 } 862 976 863 977 type RepoNewPullParams struct { ··· 882 996 RepoInfo repoinfo.RepoInfo 883 997 Pulls []*db.Pull 884 998 Active string 885 - DidHandleMap map[string]string 886 999 FilteringBy db.PullState 887 1000 Stacks map[string]db.Stack 888 1001 Pipelines map[string]db.Pipeline ··· 915 1028 LoggedInUser *oauth.User 916 1029 RepoInfo repoinfo.RepoInfo 917 1030 Active string 918 - DidHandleMap map[string]string 919 1031 Pull *db.Pull 920 1032 Stack db.Stack 921 1033 AbandonedPulls []*db.Pull ··· 935 1047 936 1048 type RepoPullPatchParams struct { 937 1049 LoggedInUser *oauth.User 938 - DidHandleMap map[string]string 939 1050 RepoInfo repoinfo.RepoInfo 940 1051 Pull *db.Pull 941 1052 Stack db.Stack ··· 953 1064 954 1065 type RepoPullInterdiffParams struct { 955 1066 LoggedInUser *oauth.User 956 - DidHandleMap map[string]string 957 1067 RepoInfo repoinfo.RepoInfo 958 1068 Pull *db.Pull 959 1069 Round int ··· 1166 1276 return p.execute("strings/dashboard", w, params) 1167 1277 } 1168 1278 1279 + type StringTimelineParams struct { 1280 + LoggedInUser *oauth.User 1281 + Strings []db.String 1282 + } 1283 + 1284 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1285 + return p.execute("strings/timeline", w, params) 1286 + } 1287 + 1169 1288 type SingleStringParams struct { 1170 1289 LoggedInUser *oauth.User 1171 1290 ShowRendered bool ··· 1182 1301 if params.ShowRendered { 1183 1302 switch markup.GetFormat(params.String.Filename) { 1184 1303 case markup.FormatMarkdown: 1185 - p.rctx.RendererType = markup.RendererTypeDefault 1304 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1186 1305 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1187 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1306 + sanitized := p.rctx.SanitizeDefault(htmlString) 1307 + params.RenderedContents = template.HTML(sanitized) 1188 1308 } 1189 1309 } 1190 1310 ··· 1217 1337 return p.execute("strings/string", w, params) 1218 1338 } 1219 1339 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1220 1344 func (p *Pages) Static() http.Handler { 1221 1345 if p.dev { 1222 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1224 1348 1225 1349 sub, err := fs.Sub(Files, "static") 1226 1350 if err != nil { 1227 - log.Fatalf("no static dir found? that's crazy: %v", err) 1351 + p.logger.Error("no static dir found? that's crazy", "err", err) 1352 + panic(err) 1228 1353 } 1229 1354 // Custom handler to apply Cache-Control headers for font files 1230 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1247 1372 func CssContentHash() string { 1248 1373 cssFile, err := Files.Open("static/tw.css") 1249 1374 if err != nil { 1250 - log.Printf("Error opening CSS file: %v", err) 1375 + slog.Debug("Error opening CSS file", "err", err) 1251 1376 return "" 1252 1377 } 1253 1378 defer cssFile.Close() 1254 1379 1255 1380 hasher := sha256.New() 1256 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1257 - log.Printf("Error hashing CSS file: %v", err) 1382 + slog.Debug("Error hashing CSS file", "err", err) 1258 1383 return "" 1259 1384 } 1260 1385 ··· 1267 1392 1268 1393 func (p *Pages) Error404(w io.Writer) error { 1269 1394 return p.execute("errors/404", w, nil) 1395 + } 1396 + 1397 + func (p *Pages) ErrorKnot404(w io.Writer) error { 1398 + return p.execute("errors/knot404", w, nil) 1270 1399 } 1271 1400 1272 1401 func (p *Pages) Error503(w io.Writer) error {
+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
+38
appview/pages/templates/banner.html
··· 1 + {{ define "banner" }} 2 + <div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200"> 3 + <details class="group p-2"> 4 + <summary class="list-none cursor-pointer"> 5 + <div class="flex gap-4 items-center"> 6 + <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span> 8 + 9 + <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span> 10 + <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span> 11 + </div> 12 + </summary> 13 + 14 + {{ if .Registrations }} 15 + <ul class="list-disc mx-12 my-2"> 16 + {{range .Registrations}} 17 + <li>Knot: {{ .Domain }}</li> 18 + {{ end }} 19 + </ul> 20 + {{ end }} 21 + 22 + {{ if .Spindles }} 23 + <ul class="list-disc mx-12 my-2"> 24 + {{range .Spindles}} 25 + <li>Spindle: {{ .Instance }}</li> 26 + {{ end }} 27 + </ul> 28 + {{ end }} 29 + 30 + <div class="mx-6"> 31 + These services may not be fully accessible until upgraded. 32 + <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations/"> 34 + Click to read the upgrade guide</a>. 35 + </div> 36 + </details> 37 + </div> 38 + {{ end }}
+24 -4
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>404 &mdash; 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 &mdash; 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 no-underline hover:no-underline gap-2"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + go back 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 8 28 {{ end }}
+35 -2
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</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-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 &mdash; 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 gap-2"> 28 + {{ i "refresh-cw" "w-4 h-4" }} 29 + try again 30 + </button> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 + {{ i "home" "w-4 h-4" }} 33 + back to home 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 6 39 {{ end }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>503 &mdash; 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 &mdash; 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 gap-2"> 21 + {{ i "refresh-cw" "w-4 h-4" }} 22 + try again 23 + </button> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 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
··· 1 + {{ define "title" }}404 &middot; 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 &mdash; 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 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
··· 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
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; 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
··· 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
··· 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 .IsNeedsUpgrade }} 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" }} needs upgrade 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
··· 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
··· 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 }}
+37 -17
appview/pages/templates/knots/index.html
··· 1 1 {{ define "title" }}knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom"> 5 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + 7 + <span class="flex items-center gap-1 text-sm"> 8 + {{ i "book" "w-3 h-3" }} 9 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 10 + docs 11 + </a> 12 + </span> 6 13 </div> 7 14 8 15 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 16 <div class="flex flex-col gap-6"> 10 17 {{ block "about" . }} {{ end }} 11 - {{ template "knots/fragments/knotListingFull" . }} 18 + {{ block "list" . }} {{ end }} 12 19 {{ block "register" . }} {{ end }} 13 20 </div> 14 21 </section> 15 22 {{ end }} 16 23 17 24 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Knots are lightweight headless servers that enable users to host Git repositories with ease. 21 - Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โ€œcommunityโ€ servers. 22 - When creating a repository, you can choose a knot to store it on. 23 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 24 - Checkout the documentation if you're interested in self-hosting. 25 - </a> 25 + <section class="rounded"> 26 + <p class="text-gray-500 dark:text-gray-400"> 27 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 28 + When creating a repository, you can choose a knot to store it on. 26 29 </p> 30 + 31 + 32 + </section> 33 + {{ end }} 34 + 35 + {{ define "list" }} 36 + <section class="rounded w-full flex flex-col gap-2"> 37 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 38 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 39 + {{ range $registration := .Registrations }} 40 + {{ template "knots/fragments/knotListing" . }} 41 + {{ else }} 42 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 43 + no knots registered yet 44 + </div> 45 + {{ end }} 46 + </div> 47 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 27 48 </section> 28 49 {{ end }} 29 50 30 51 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 52 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 32 53 <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> 54 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 34 55 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 56 + hx-post="/knots/register" 57 + class="max-w-2xl mb-2 space-y-4" 37 58 hx-indicator="#register-button" 38 59 hx-swap="none" 39 60 > ··· 53 74 > 54 75 <span class="inline-flex items-center gap-2"> 55 76 {{ i "plus" "w-4 h-4" }} 56 - generate 77 + register 57 78 </span> 58 79 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 80 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 82 </button> 62 83 </div> 63 84 64 - <div id="registration-error" class="error dark:text-red-400"></div> 85 + <div id="register-error" class="error dark:text-red-400"></div> 65 86 </form> 66 87 67 - <div id="secret"></div> 68 88 </section> 69 89 {{ end }}
+27 -24
appview/pages/templates/layouts/base.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 + <meta name="description" content="Social coding, but for real this time!"/> 10 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 11 - <script src="/static/htmx.min.js"></script> 12 - <script src="/static/htmx-ext-ws.min.js"></script> 9 + 10 + <script defer src="/static/htmx.min.js"></script> 11 + <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + 13 + <!-- preconnect to image cdn --> 14 + <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 + <link rel="preconnect" href="https://camo.tangled.sh" /> 16 + 17 + <!-- preload main font --> 18 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 + 13 20 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 14 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 22 {{ block "extrameta" . }}{{ end }} 16 23 </head> 17 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 25 {{ block "topbarLayout" . }} 19 - <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 - {{ template "layouts/topbar" . }} 26 + <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 27 + 28 + {{ if .LoggedInUser }} 29 + <div id="upgrade-banner" 30 + hx-get="/upgradeBanner" 31 + hx-trigger="load" 32 + hx-swap="innerHTML"> 33 + </div> 34 + {{ end }} 35 + {{ template "layouts/fragments/topbar" . }} 21 36 </header> 22 37 {{ end }} 23 38 24 39 {{ block "mainLayout" . }} 25 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 40 + <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 26 41 {{ block "contentLayout" . }} 27 - <div class="col-span-1 md:col-span-2"> 28 - {{ block "contentLeft" . }} {{ end }} 29 - </div> 30 42 <main class="col-span-1 md:col-span-8"> 31 43 {{ block "content" . }}{{ end }} 32 44 </main> 33 - <div class="col-span-1 md:col-span-2"> 34 - {{ block "contentRight" . }} {{ end }} 35 - </div> 36 45 {{ end }} 37 46 38 47 {{ block "contentAfterLayout" . }} 39 - <div class="col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 48 <main class="col-span-1 md:col-span-8"> 43 49 {{ block "contentAfter" . }}{{ end }} 44 50 </main> 45 - <div class="col-span-1 md:col-span-2"> 46 - {{ block "contentAfterRight" . }} {{ end }} 47 - </div> 48 51 {{ end }} 49 52 </div> 50 53 {{ end }} 51 54 52 55 {{ block "footerLayout" . }} 53 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 - {{ template "layouts/footer" . }} 56 + <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 57 + {{ template "layouts/fragments/footer" . }} 55 58 </footer> 56 59 {{ end }} 57 60 </body>
-48
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 - tangled<sub>alpha</sub> 8 - </a> 9 - </div> 10 - 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 - </div> 20 - 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - </div> 27 - 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 - </div> 34 - 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 - </div> 40 - </div> 41 - 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 - </div> 45 - </div> 46 - </div> 47 - </div> 48 - {{ end }}
+48
appview/pages/templates/layouts/fragments/footer.html
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 + </div> 46 + </div> 47 + </div> 48 + {{ end }}
+80
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 + {{ end }} 25 + 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left nav-dropdown"> 28 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 + {{ i "plus" "w-4 h-4" }} new 30 + </summary> 31 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 + <a href="/repo/new" class="flex items-center gap-2"> 33 + {{ i "book-plus" "w-4 h-4" }} 34 + new repository 35 + </a> 36 + <a href="/strings/new" class="flex items-center gap-2"> 37 + {{ i "line-squiggle" "w-4 h-4" }} 38 + new string 39 + </a> 40 + </div> 41 + </details> 42 + {{ end }} 43 + 44 + {{ define "dropDown" }} 45 + <details class="relative inline-block text-left nav-dropdown"> 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="/{{ $user }}?tab=strings">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 + 70 + <script> 71 + document.addEventListener('click', function(event) { 72 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 73 + dropdowns.forEach(function(dropdown) { 74 + if (!dropdown.contains(event.target)) { 75 + dropdown.removeAttribute('open'); 76 + } 77 + }); 78 + }); 79 + </script> 80 + {{ end }}
+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 +
+20 -29
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" . }} 47 42 </section> 48 43 49 44 <section 50 - class="w-full flex flex-col drop-shadow-sm" 45 + class="w-full flex flex-col" 51 46 > 52 47 <nav class="w-full pl-4 overflow-auto"> 53 48 <div class="flex z-60"> ··· 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> ··· 86 81 </div> 87 82 </nav> 88 83 <section 89 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 90 85 > 91 86 {{ block "repoContent" . }}{{ end }} 92 87 </section> 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
··· 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 }}
+4 -126
appview/pages/templates/legal/privacy.html
··· 1 - {{ define "title" }} privacy policy {{ end }} 1 + {{ define "title" }}privacy policy{{ end }} 2 + 2 3 {{ define "content" }} 3 4 <div class="max-w-4xl mx-auto px-4 py-8"> 4 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 6 - <h1>Privacy Policy</h1> 7 - 8 - <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 - 10 - <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 - 12 - <h2>1. Information We Collect</h2> 13 - 14 - <h3>Account Information</h3> 15 - <p>When you create an account, we collect:</p> 16 - <ul> 17 - <li>Your chosen username</li> 18 - <li>Email address</li> 19 - <li>Profile information you choose to provide</li> 20 - <li>Authentication data</li> 21 - </ul> 22 - 23 - <h3>Content and Activity</h3> 24 - <p>We store:</p> 25 - <ul> 26 - <li>Code repositories and associated metadata</li> 27 - <li>Issues, pull requests, and comments</li> 28 - <li>Activity logs and usage patterns</li> 29 - <li>Public keys for authentication</li> 30 - </ul> 31 - 32 - <h2>2. Data Location and Hosting</h2> 33 - <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 - <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 - <p class="text-blue-700 dark:text-blue-300"> 36 - <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 - </p> 38 - <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 - <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 - <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 - <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 - </ul> 43 - </div> 44 - 45 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 - <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 - <p class="text-yellow-700 dark:text-yellow-300"> 48 - <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 - </p> 50 - </div> 51 - 52 - <h2>3. Third-Party Data Processors</h2> 53 - <p>We only share your data with the following third-party processors:</p> 54 - 55 - <h3>Resend (Email Services)</h3> 56 - <ul> 57 - <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 - <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 - <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 - </ul> 61 - 62 - <h3>Cloudflare (Image Caching)</h3> 63 - <ul> 64 - <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 - <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 - <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 - </ul> 68 - 69 - <h2>4. How We Use Your Information</h2> 70 - <p>We use your information to:</p> 71 - <ul> 72 - <li>Provide and maintain the Service</li> 73 - <li>Process your transactions and requests</li> 74 - <li>Send you technical notices and support messages</li> 75 - <li>Improve and develop new features</li> 76 - <li>Ensure security and prevent fraud</li> 77 - <li>Comply with legal obligations</li> 78 - </ul> 79 - 80 - <h2>5. Data Sharing and Disclosure</h2> 81 - <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 - <ul> 83 - <li>With the third-party processors listed above</li> 84 - <li>When required by law or legal process</li> 85 - <li>To protect our rights, property, or safety, or that of our users</li> 86 - <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 - </ul> 88 - 89 - <h2>6. Data Security</h2> 90 - <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 - 92 - <h2>7. Data Retention</h2> 93 - <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 - 95 - <h2>8. Your Rights</h2> 96 - <p>Under applicable data protection laws, you have the right to:</p> 97 - <ul> 98 - <li>Access your personal information</li> 99 - <li>Correct inaccurate information</li> 100 - <li>Request deletion of your information</li> 101 - <li>Object to processing of your information</li> 102 - <li>Data portability</li> 103 - <li>Withdraw consent (where applicable)</li> 104 - </ul> 105 - 106 - <h2>9. Cookies and Tracking</h2> 107 - <p>We use cookies and similar technologies to:</p> 108 - <ul> 109 - <li>Maintain your login session</li> 110 - <li>Remember your preferences</li> 111 - <li>Analyze usage patterns to improve the Service</li> 112 - </ul> 113 - <p>You can control cookie settings through your browser preferences.</p> 114 - 115 - <h2>10. Children's Privacy</h2> 116 - <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 - 118 - <h2>11. International Data Transfers</h2> 119 - <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 - 121 - <h2>12. Changes to This Privacy Policy</h2> 122 - <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 - 124 - <h2>13. Contact Information</h2> 125 - <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 - 127 - <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 - <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 - </div> 7 + {{ .Content }} 130 8 </div> 131 9 </div> 132 10 </div> 133 - {{ end }} 11 + {{ end }}
+2 -62
appview/pages/templates/legal/terms.html
··· 4 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - <h1>Terms of Service</h1> 8 - 9 - <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 - 11 - <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 - 13 - <h2>1. Acceptance of Terms</h2> 14 - <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 - 16 - <h2>2. Account Registration</h2> 17 - <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 - 19 - <h2>3. Account Termination</h2> 20 - <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 - <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 - <p class="text-red-700 dark:text-red-300"> 23 - <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 - </p> 25 - <p class="text-red-700 dark:text-red-300 mt-2"> 26 - Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 - </p> 28 - </div> 29 - 30 - <h2>4. Acceptable Use</h2> 31 - <p>You agree not to use the Service to:</p> 32 - <ul> 33 - <li>Violate any applicable laws or regulations</li> 34 - <li>Infringe upon the rights of others</li> 35 - <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 - <li>Engage in spam, phishing, or other deceptive practices</li> 37 - <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 - <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 - </ul> 40 - 41 - <h2>5. Content and Intellectual Property</h2> 42 - <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 - 44 - <h2>6. Privacy</h2> 45 - <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 - 47 - <h2>7. Disclaimers</h2> 48 - <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 - 50 - <h2>8. Limitation of Liability</h2> 51 - <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 - 53 - <h2>9. Indemnification</h2> 54 - <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 - 56 - <h2>10. Governing Law</h2> 57 - <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 - 59 - <h2>11. Changes to Terms</h2> 60 - <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 - 62 - <h2>12. Contact Information</h2> 63 - <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 - 65 - <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 - <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 - </div> 7 + {{ .Content }} 68 8 </div> 69 9 </div> 70 10 </div> 71 - {{ end }} 11 + {{ end }}
+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
··· 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
··· 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 }}
+9 -3
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"> ··· 19 19 class="mr-2" 20 20 id="domain-{{ . }}" 21 21 /> 22 - <span class="dark:text-white">{{ . }}</span> 22 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 23 </div> 24 24 {{ else }} 25 25 <p class="dark:text-white">No knots available.</p> ··· 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
··· 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
··· 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 }}
+35 -83
appview/pages/templates/repo/fragments/diff.html
··· 11 11 {{ $last := sub (len $diff) 1 }} 12 12 13 13 <div class="flex flex-col gap-4"> 14 + {{ if eq (len $diff) 0 }} 15 + <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 16 + <p>No differences found between the selected revisions.</p> 17 + </div> 18 + {{ else }} 14 19 {{ range $idx, $hunk := $diff }} 15 20 {{ 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> 21 + <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 }}"> 22 + <summary class="list-none cursor-pointer sticky top-0"> 23 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 24 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 25 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 26 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 27 + {{ template "repo/fragments/diffStatPill" .Stats }} 74 28 75 - </div> 76 - </summary> 77 - 78 - <div class="transition-all duration-700 ease-in-out"> 79 - {{ if .IsDelete }} 80 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 81 - This file has been deleted. 82 - </p> 83 - {{ else if .IsCopy }} 84 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 85 - This file has been copied. 86 - </p> 87 - {{ else if .IsBinary }} 88 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 89 - This is a binary file and will not be displayed. 90 - </p> 91 - {{ else }} 92 - {{ if $isSplit }} 93 - {{- template "repo/fragments/splitDiff" .Split -}} 29 + <div class="flex gap-2 items-center overflow-x-auto"> 30 + {{ if .IsDelete }} 31 + {{ .Name.Old }} 32 + {{ else if (or .IsCopy .IsRename) }} 33 + {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 94 34 {{ else }} 95 - {{- template "repo/fragments/unifiedDiff" . -}} 35 + {{ .Name.New }} 96 36 {{ end }} 97 - {{- end -}} 37 + </div> 98 38 </div> 39 + </div> 40 + </summary> 99 41 100 - </details> 101 - 42 + <div class="transition-all duration-700 ease-in-out"> 43 + {{ if .IsBinary }} 44 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 45 + This is a binary file and will not be displayed. 46 + </p> 47 + {{ else }} 48 + {{ if $isSplit }} 49 + {{- template "repo/fragments/splitDiff" .Split -}} 50 + {{ else }} 51 + {{- template "repo/fragments/unifiedDiff" . -}} 52 + {{ end }} 53 + {{- end -}} 102 54 </div> 103 - </div> 104 - </section> 55 + </details> 105 56 {{ end }} 57 + {{ end }} 106 58 {{ end }} 107 59 </div> 108 60 {{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+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
··· 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 {{ 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>
+6
appview/pages/templates/repo/fragments/languageBall.html
··· 1 + {{ define "repo/fragments/languageBall" }} 2 + <div 3 + class="size-2 rounded-full" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));" 5 + ></div> 6 + {{ end }}
+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
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 + {{ end }} 4 +
-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
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+116 -117
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"> ··· 34 35 {{ end }} 35 36 36 37 {{ define "repoLanguages" }} 37 - <div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t"> 38 + <details class="group -m-6 mb-4"> 39 + <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 38 40 {{ range $value := .Languages }} 39 - <div 40 - title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 41 - class="h-[4px] rounded-full" 42 - style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 43 - ></div> 41 + <div 42 + title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 43 + style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 + ></div> 45 + {{ end }} 46 + </summary> 47 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 + {{ range $value := .Languages }} 49 + <div 50 + class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 + > 52 + {{ template "repo/fragments/languageBall" $value.Name }} 53 + <div>{{ or $value.Name "Other" }} 54 + <span class="text-gray-500 dark:text-gray-400"> 55 + {{ if lt $value.Percentage 0.05 }} 56 + 0.1% 57 + {{ else }} 58 + {{ printf "%.1f" $value.Percentage }}% 59 + {{ end }} 60 + </span></div> 61 + </div> 44 62 {{ end }} 45 - </div> 63 + </div> 64 + </details> 46 65 {{ end }} 47 - 48 66 49 67 {{ 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 }} 68 + <div class="flex gap-2 items-center justify-between w-full"> 69 + <div class="flex gap-2 items-center"> 70 + <select 71 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 73 + > 74 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 75 + {{ range .Branches }} 76 + <option 77 + value="{{ .Reference.Name }}" 78 + class="py-1" 79 + {{ if eq .Reference.Name $.Ref }} 80 + selected 81 + {{ end }} 82 + > 83 + {{ .Reference.Name }} 84 + </option> 85 + {{ end }} 86 + </optgroup> 87 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 88 + {{ range .Tags }} 89 + <option 90 + value="{{ .Reference.Name }}" 91 + class="py-1" 92 + {{ if eq .Reference.Name $.Ref }} 93 + selected 94 + {{ end }} 95 + > 96 + {{ .Reference.Name }} 97 + </option> 98 + {{ else }} 99 + <option class="py-1" disabled>no tags found</option> 100 + {{ end }} 101 + </optgroup> 102 + </select> 103 + <div class="flex items-center gap-2"> 104 + <a 105 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 106 + class="btn flex items-center gap-2 no-underline hover:no-underline" 107 + title="Compare branches or tags" 108 + > 109 + {{ i "git-compare" "w-4 h-4" }} 110 + </a> 111 + </div> 112 + </div> 100 113 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> 114 + <!-- Clone dropdown in top right --> 115 + <div class="hidden md:flex items-center "> 116 + {{ template "repo/fragments/cloneDropdown" . }} 125 117 </div> 126 - </div> 118 + </div> 127 119 {{ end }} 128 120 129 121 {{ define "fileTree" }} ··· 131 123 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 124 133 125 {{ range .Files }} 134 - <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 - <div class="col-span-1"> 126 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 127 + <div class="col-span-2"> 136 128 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 129 {{ $icon := "folder" }} 138 130 {{ $iconStyle := "size-4 fill-current" }} ··· 144 136 {{ end }} 145 137 <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 138 <div class="flex items-center gap-2"> 147 - {{ i $icon $iconStyle }}{{ .Name }} 139 + {{ i $icon $iconStyle "flex-shrink-0" }} 140 + <span class="truncate">{{ .Name }}</span> 148 141 </div> 149 142 </a> 150 143 </div> 151 144 152 - <div class="text-xs col-span-1 text-right"> 145 + <div class="text-sm col-span-1 text-right"> 153 146 {{ with .LastCommit }} 154 147 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 148 {{ end }} ··· 210 203 </div> 211 204 212 205 <!-- commit info bar --> 213 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 206 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 214 207 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 215 208 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 216 209 {{ if $verified }} ··· 280 273 </a> 281 274 <div class="flex flex-col gap-1"> 282 275 {{ range .BranchesTrunc }} 283 - <div class="text-base flex items-center justify-between"> 284 - <div class="flex items-center gap-2"> 276 + <div class="text-base flex items-center justify-between overflow-hidden"> 277 + <div class="flex items-center gap-2 min-w-0 flex-1"> 285 278 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 286 - class="inline no-underline hover:underline dark:text-white"> 279 + class="inline-block truncate no-underline hover:underline dark:text-white"> 287 280 {{ .Reference.Name }} 288 281 </a> 289 282 {{ 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> 283 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 284 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 292 285 {{ end }} 293 286 {{ 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> 287 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 288 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 296 289 {{ end }} 297 290 </div> 298 291 {{ if ne $.Ref .Reference.Name }} 299 292 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 300 - class="text-xs flex gap-2 items-center" 293 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 301 294 title="Compare branches or tags"> 302 295 {{ i "git-compare" "w-3 h-3" }} compare 303 296 </a> 304 - {{end}} 297 + {{ end }} 305 298 </div> 306 299 {{ end }} 307 300 </div> ··· 347 340 348 341 {{ define "repoAfter" }} 349 342 {{- 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> 343 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 344 + {{- if .ReadmeFileName -}} 345 + <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"> 346 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 347 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 348 + </div> 349 + {{- end -}} 350 + <section 351 + class="p-6 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> 364 + </div> 364 365 {{- end -}} 365 - 366 - {{ template "repo/fragments/cloneInstructions" . }} 367 366 {{ end }}
+58
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 + {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-8"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListing" (list $ .) }} 5 + {{ end }} 6 + <div> 7 + {{ end }} 8 + 9 + {{ define "commentListing" }} 10 + {{ $root := index . 0 }} 11 + {{ $comment := index . 1 }} 12 + {{ $params := 13 + (dict 14 + "RepoInfo" $root.RepoInfo 15 + "LoggedInUser" $root.LoggedInUser 16 + "Issue" $root.Issue 17 + "Comment" $comment.Self) }} 18 + 19 + <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 20 + {{ template "topLevelComment" $params }} 21 + 22 + <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 23 + {{ range $index, $reply := $comment.Replies }} 24 + <div class="relative "> 25 + <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 27 + 28 + <div class="pl-2"> 29 + {{ 30 + template "replyComment" 31 + (dict 32 + "RepoInfo" $root.RepoInfo 33 + "LoggedInUser" $root.LoggedInUser 34 + "Issue" $root.Issue 35 + "Comment" $reply) 36 + }} 37 + </div> 38 + </div> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ define "topLevelComment" }} 47 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 + {{ template "repo/issues/fragments/issueCommentBody" . }} 50 + </div> 51 + {{ end }} 52 + 53 + {{ define "replyComment" }} 54 + <div class="p-4 w-full mx-auto overflow-hidden"> 55 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 + {{ template "repo/issues/fragments/issueCommentBody" . }} 57 + </div> 58 + {{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 7 9 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['ยท']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 41 13 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 48 18 </div> 49 - {{ end }} 19 + {{ end }} 20 + 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 50 32 {{ end }} 51 33 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-59
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - {{ template "user/fragments/picHandleLink" $owner }} 7 - 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['ยท']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ if .Deleted }} 21 - deleted {{ template "repo/fragments/time" .Deleted }} 22 - {{ else if .Edited }} 23 - edited {{ template "repo/fragments/time" .Edited }} 24 - {{ else }} 25 - {{ template "repo/fragments/time" .Created }} 26 - {{ end }} 27 - </a> 28 - 29 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 30 - {{ if and $isCommentOwner (not .Deleted) }} 31 - <button 32 - class="btn px-2 py-1 text-sm" 33 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 34 - hx-swap="outerHTML" 35 - hx-target="#comment-container-{{.CommentId}}" 36 - > 37 - {{ i "pencil" "w-4 h-4" }} 38 - </button> 39 - <button 40 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 41 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 42 - hx-confirm="Are you sure you want to delete your comment?" 43 - hx-swap="outerHTML" 44 - hx-target="#comment-container-{{.CommentId}}" 45 - > 46 - {{ i "trash-2" "w-4 h-4" }} 47 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 48 - </button> 49 - {{ end }} 50 - 51 - </div> 52 - {{ if not .Deleted }} 53 - <div class="prose dark:prose-invert"> 54 - {{ .Body | markdown }} 55 - </div> 56 - {{ end }} 57 - </div> 58 - {{ end }} 59 - {{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
··· 1 + {{ define "repo/issues/fragments/issueCommentActions" }} 2 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 3 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 4 + <div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2"> 5 + {{ template "edit" . }} 6 + {{ template "delete" . }} 7 + </div> 8 + {{ end }} 9 + {{ end }} 10 + 11 + {{ define "edit" }} 12 + <a 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 14 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 + hx-swap="outerHTML" 16 + hx-target="#comment-body-{{.Comment.Id}}"> 17 + {{ i "pencil" "size-3" }} 18 + edit 19 + </a> 20 + {{ end }} 21 + 22 + {{ define "delete" }} 23 + <a 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 25 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 + hx-confirm="Are you sure you want to delete your comment?" 27 + hx-swap="outerHTML" 28 + hx-target="#comment-body-{{.Comment.Id}}" 29 + > 30 + {{ i "trash-2" "size-3" }} 31 + delete 32 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </a> 34 + {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 5 + {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 + {{ template "editIssueComment" . }} 9 + {{ template "deleteIssueComment" . }} 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ end }} 32 + </a> 33 + {{ end }} 34 + 35 + {{ define "editIssueComment" }} 36 + <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 + hx-swap="outerHTML" 40 + hx-target="#comment-body-{{.Comment.Id}}"> 41 + {{ i "pencil" "size-3" }} 42 + </a> 43 + {{ end }} 44 + 45 + {{ define "deleteIssueComment" }} 46 + <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 + hx-confirm="Are you sure you want to delete your comment?" 50 + hx-swap="outerHTML" 51 + hx-target="#comment-body-{{.Comment.Id}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 38 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 39 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 40 + {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 41 + {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }} 42 + <button 43 + id="close-button" 44 + type="button" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 84 + } 85 + }); 86 + </script> 87 + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 88 + <button 89 + type="button" 90 + class="btn flex items-center gap-2" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 143 + </div> 144 + {{ end }} 145 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 + {{ define "repo/issues/fragments/replyComment" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + id="reply-form-{{ .Comment.Id }}" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 + > 9 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 10 + <textarea 11 + id="reply-{{.Comment.Id}}-textarea" 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 21 + 22 + <input 23 + type="text" 24 + id="reply-to" 25 + name="reply-to" 26 + required 27 + value="{{ .Comment.AtUri }}" 28 + class="hidden" 29 + /> 30 + {{ template "replyActions" . }} 31 + </form> 32 + {{ end }} 33 + 34 + {{ define "replyActions" }} 35 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 36 + {{ template "cancel" . }} 37 + {{ template "reply" . }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ define "cancel" }} 42 + <button 43 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 44 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 45 + hx-target="#reply-form-{{ .Comment.Id }}" 46 + hx-swap="outerHTML"> 47 + {{ i "x" "size-4" }} 48 + cancel 49 + </button> 50 + {{ end }} 51 + 52 + {{ define "reply" }} 53 + <button 54 + id="reply-{{ .Comment.Id }}" 55 + type="submit" 56 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 57 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + reply 60 + </button> 61 + {{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 + {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full py-2 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+95 -202
appview/pages/templates/repo/issues/issue.html
··· 9 9 {{ end }} 10 10 11 11 {{ define "repoContent" }} 12 - <header class="pb-4"> 13 - <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 15 - <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 - </h1> 17 - </header> 12 + <section id="issue-{{ .Issue.IssueId }}"> 13 + {{ template "issueHeader" .Issue }} 14 + {{ template "issueInfo" . }} 15 + {{ if .Issue.Body }} 16 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 + {{ end }} 18 + {{ template "issueReactions" . }} 19 + </section> 20 + {{ end }} 18 21 19 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 20 - {{ $icon := "ban" }} 21 - {{ if eq .State "open" }} 22 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 23 - {{ $icon = "circle-dot" }} 24 - {{ end }} 22 + {{ define "issueHeader" }} 23 + <header class="pb-2"> 24 + <h1 class="text-2xl"> 25 + {{ .Title | description }} 26 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 27 + </h1> 28 + </header> 29 + {{ end }} 25 30 26 - <section class="mt-2"> 27 - <div class="inline-flex items-center gap-2"> 28 - <div id="state" 29 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .State }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 - opened by 35 - {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandleLink" $owner }} 37 - <span class="select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" .Issue.Created }} 39 - </span> 40 - </div> 31 + {{ define "issueInfo" }} 32 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 33 + {{ $icon := "ban" }} 34 + {{ if eq .Issue.State "open" }} 35 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 36 + {{ $icon = "circle-dot" }} 37 + {{ end }} 38 + <div class="inline-flex items-center gap-2"> 39 + <div id="state" 40 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 41 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 42 + <span class="text-white">{{ .Issue.State }}</span> 43 + </div> 44 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 45 + opened by 46 + {{ template "user/fragments/picHandleLink" .Issue.Did }} 47 + <span class="select-none before:content-['\00B7']"></span> 48 + {{ if .Issue.Edited }} 49 + edited {{ template "repo/fragments/time" .Issue.Edited }} 50 + {{ else }} 51 + {{ template "repo/fragments/time" .Issue.Created }} 52 + {{ end }} 53 + </span> 41 54 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 55 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 56 + {{ template "issueActions" . }} 57 + {{ end }} 58 + </div> 59 + <div id="issue-actions-error" class="error"></div> 60 + {{ end }} 47 61 48 - <div class="flex items-center gap-2 mt-2"> 49 - {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 - {{ range $kind := .OrderedReactionKinds }} 51 - {{ 52 - template "repo/fragments/reaction" 53 - (dict 54 - "Kind" $kind 55 - "Count" (index $.Reactions $kind) 56 - "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 58 - }} 59 - {{ end }} 60 - </div> 61 - </section> 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 62 65 {{ end }} 63 66 64 - {{ define "repoAfter" }} 65 - <section id="comments" class="my-2 mt-2 space-y-2 relative"> 66 - {{ range $index, $comment := .Comments }} 67 - <div 68 - id="comment-{{ .CommentId }}" 69 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 70 - {{ if gt $index 0 }} 71 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 - {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 74 - </div> 75 - {{ end }} 76 - </section> 67 + {{ define "editIssue" }} 68 + <a 69 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 70 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 71 + hx-swap="innerHTML" 72 + hx-target="#issue-{{.Issue.IssueId}}"> 73 + {{ i "pencil" "size-3" }} 74 + </a> 75 + {{ end }} 77 76 78 - {{ block "newComment" . }} {{ end }} 77 + {{ define "deleteIssue" }} 78 + <a 79 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 80 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 81 + hx-confirm="Are you sure you want to delete your issue?" 82 + hx-swap="none"> 83 + {{ i "trash-2" "size-3" }} 84 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </a> 86 + {{ end }} 79 87 88 + {{ define "issueReactions" }} 89 + <div class="flex items-center gap-2 mt-2"> 90 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 + {{ range $kind := .OrderedReactionKinds }} 92 + {{ 93 + template "repo/fragments/reaction" 94 + (dict 95 + "Kind" $kind 96 + "Count" (index $.Reactions $kind) 97 + "IsReacted" (index $.UserReacted $kind) 98 + "ThreadAt" $.Issue.AtUri) 99 + }} 100 + {{ end }} 101 + </div> 80 102 {{ end }} 81 103 82 - {{ define "newComment" }} 83 - {{ if .LoggedInUser }} 84 - <form 85 - id="comment-form" 86 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 87 - hx-on::after-request="if(event.detail.successful) this.reset()" 88 - > 89 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 90 - <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 91 - {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 92 - </div> 93 - <textarea 94 - id="comment-textarea" 95 - name="body" 96 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 97 - placeholder="Add to the discussion. Markdown is supported." 98 - onkeyup="updateCommentForm()" 99 - ></textarea> 100 - <div id="issue-comment"></div> 101 - <div id="issue-action" class="error"></div> 102 - </div> 103 - 104 - <div class="flex gap-2 mt-2"> 105 - <button 106 - id="comment-button" 107 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 108 - type="submit" 109 - hx-disabled-elt="#comment-button" 110 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group" 111 - disabled 112 - > 113 - {{ i "message-square-plus" "w-4 h-4" }} 114 - comment 115 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 116 - </button> 117 - 118 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 119 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 120 - {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 121 - {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }} 122 - <button 123 - id="close-button" 124 - type="button" 125 - class="btn flex items-center gap-2" 126 - hx-indicator="#close-spinner" 127 - hx-trigger="click" 128 - > 129 - {{ i "ban" "w-4 h-4" }} 130 - close 131 - <span id="close-spinner" class="group"> 132 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 133 - </span> 134 - </button> 135 - <div 136 - id="close-with-comment" 137 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 138 - hx-trigger="click from:#close-button" 139 - hx-disabled-elt="#close-with-comment" 140 - hx-target="#issue-comment" 141 - hx-indicator="#close-spinner" 142 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 143 - hx-swap="none" 144 - > 145 - </div> 146 - <div 147 - id="close-issue" 148 - hx-disabled-elt="#close-issue" 149 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 150 - hx-trigger="click from:#close-button" 151 - hx-target="#issue-action" 152 - hx-indicator="#close-spinner" 153 - hx-swap="none" 154 - > 155 - </div> 156 - <script> 157 - document.addEventListener('htmx:configRequest', function(evt) { 158 - if (evt.target.id === 'close-with-comment') { 159 - const commentText = document.getElementById('comment-textarea').value.trim(); 160 - if (commentText === '') { 161 - evt.detail.parameters = {}; 162 - evt.preventDefault(); 163 - } 164 - } 165 - }); 166 - </script> 167 - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} 168 - <button 169 - type="button" 170 - class="btn flex items-center gap-2" 171 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 172 - hx-indicator="#reopen-spinner" 173 - hx-swap="none" 174 - > 175 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 176 - reopen 177 - <span id="reopen-spinner" class="group"> 178 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 179 - </span> 180 - </button> 181 - {{ end }} 182 - 183 - <script> 184 - function updateCommentForm() { 185 - const textarea = document.getElementById('comment-textarea'); 186 - const commentButton = document.getElementById('comment-button'); 187 - const closeButton = document.getElementById('close-button'); 188 - 189 - if (textarea.value.trim() !== '') { 190 - commentButton.removeAttribute('disabled'); 191 - } else { 192 - commentButton.setAttribute('disabled', ''); 193 - } 104 + {{ define "repoAfter" }} 105 + <div class="flex flex-col gap-4 mt-4"> 106 + {{ 107 + template "repo/issues/fragments/commentList" 108 + (dict 109 + "RepoInfo" $.RepoInfo 110 + "LoggedInUser" $.LoggedInUser 111 + "Issue" $.Issue 112 + "CommentList" $.Issue.CommentList) 113 + }} 194 114 195 - if (closeButton) { 196 - if (textarea.value.trim() !== '') { 197 - closeButton.innerHTML = ` 198 - {{ i "ban" "w-4 h-4" }} 199 - <span>close with comment</span> 200 - <span id="close-spinner" class="group"> 201 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 202 - </span>`; 203 - } else { 204 - closeButton.innerHTML = ` 205 - {{ i "ban" "w-4 h-4" }} 206 - <span>close</span> 207 - <span id="close-spinner" class="group"> 208 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 209 - </span>`; 210 - } 211 - } 212 - } 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 117 + {{ end }} 213 118 214 - document.addEventListener('DOMContentLoaded', function() { 215 - updateCommentForm(); 216 - }); 217 - </script> 218 - </div> 219 - </form> 220 - {{ else }} 221 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 222 - <a href="/login" class="underline">login</a> to join the discussion 223 - </div> 224 - {{ end }} 225 - {{ end }}
+42 -45
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 61 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 66 67 - <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandleLink" $owner }} 70 - </span> 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 71 70 72 - <span class="before:content-['ยท']"> 73 - {{ template "repo/fragments/time" .Created }} 74 - </span> 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 75 74 76 - <span class="before:content-['ยท']"> 77 - {{ $s := "s" }} 78 - {{ if eq .Metadata.CommentCount 1 }} 79 - {{ $s = "" }} 80 - {{ end }} 81 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 82 - </span> 83 - </p> 75 + <span class="before:content-['ยท']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + </p> 83 + </div> 84 + {{ end }} 84 85 </div> 85 - {{ end }} 86 - </div> 87 - 88 - {{ block "pagination" . }} {{ end }} 89 - 86 + {{ block "pagination" . }} {{ end }} 90 87 {{ end }} 91 88 92 89 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="mt-6 space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn-create flex items-center gap-2"> 27 - {{ i "circle-plus" "w-4 h-4" }} 28 - create issue 29 - <span id="create-pull-spinner" class="group"> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 32 - </button> 33 - </div> 34 - </div> 35 - <div id="issues" class="error"></div> 36 - </form> 4 + {{ template "repo/issues/fragments/putIssue" . }} 37 5 {{ end }}
+60
appview/pages/templates/repo/needsUpgrade.html
··· 1 + {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 + {{ define "extrameta" }} 3 + {{ template "repo/fragments/meta" . }} 4 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 5 + {{ end }} 6 + {{ define "repoContent" }} 7 + <main> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600"> 10 + <!-- mimic the repo view here, placeholders are LLM generated --> 11 + <div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 12 + {{ $files := 13 + (list 14 + "src" 15 + "docs" 16 + "config" 17 + "lib" 18 + "index.html" 19 + "log.html" 20 + "needsUpgrade.html" 21 + "new.html" 22 + "tags.html" 23 + "tree.html") 24 + }} 25 + {{ range $files }} 26 + <span> 27 + {{ if (contains . ".") }} 28 + {{ i "file" "size-4 inline-flex" }} 29 + {{ else }} 30 + {{ i "folder" "size-4 inline-flex fill-current" }} 31 + {{ end }} 32 + 33 + {{ . }} 34 + </span> 35 + {{ end }} 36 + </div> 37 + <div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 38 + {{ $commits := 39 + (list 40 + "Fix authentication bug in login flow" 41 + "Add new dashboard widgets for metrics" 42 + "Implement real-time notifications system") 43 + }} 44 + {{ range $commits }} 45 + <div class="flex flex-col"> 46 + <span>{{ . }}</span> 47 + <span class="text-xs">{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + </div> 51 + </div> 52 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 53 + <div class="text-center"> 54 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 55 + The knot hosting this repository needs an upgrade. This repository is currently unavailable. 56 + </div> 57 + </div> 58 + </div> 59 + </main> 60 + {{ end }}
+2 -2
appview/pages/templates/repo/new.html
··· 49 49 class="mr-2" 50 50 id="domain-{{ . }}" 51 51 /> 52 - <span class="dark:text-white">{{ . }}</span> 52 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 53 </div> 54 54 {{ else }} 55 55 <p class="dark:text-white">No knots available.</p> ··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 - {{ define "title" }}settings &middot; {{ .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 }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+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
··· 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
··· 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
··· 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"
+17 -10
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 - {{ if .Verified }} 33 + 34 + {{ if .NeedsUpgrade }} 35 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span> 36 + {{ block "spindleRetryButton" . }} {{ end }} 37 + {{ else if .Verified }} 32 38 <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 39 {{ template "spindles/fragments/addMemberModal" . }} 34 40 {{ else }} 35 41 <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 }} 42 + {{ block "spindleRetryButton" . }} {{ end }} 37 43 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 44 + 45 + {{ block "spindleDeleteButton" . }} {{ end }} 39 46 </div> 40 47 {{ end }} 41 48 42 - {{ define "deleteButton" }} 49 + {{ define "spindleDeleteButton" }} 43 50 <button 44 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 52 title="Delete spindle" ··· 55 62 {{ end }} 56 63 57 64 58 - {{ define "retryButton" }} 65 + {{ define "spindleRetryButton" }} 59 66 <button 60 67 class="btn gap-2 group" 61 68 title="Retry spindle verification"
+14 -9
appview/pages/templates/spindles/index.html
··· 1 1 {{ define "title" }}spindles{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 4 + <div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + 7 + 8 + <span class="flex items-center gap-1 text-sm"> 9 + {{ i "book" "w-3 h-3" }} 10 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 11 + docs 12 + </a> 13 + </span> 6 14 </div> 7 15 8 16 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 23 {{ end }} 16 24 17 25 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Spindles are small CI runners. 21 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 22 - Checkout the documentation if you're interested in self-hosting. 23 - </a> 26 + <section class="rounded flex items-center gap-2"> 27 + <p class="text-gray-500 dark:text-gray-400"> 28 + Spindles are small CI runners. 24 29 </p> 25 - </section> 30 + </section> 26 31 {{ end }} 27 32 28 33 {{ define "list" }}
+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
··· 1 1 {{ define "title" }}publish a new string{{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 <div class="px-6 py-2 mb-4"> 9 5 {{ if eq .Action "new" }}
+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
··· 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 +
+34
appview/pages/templates/timeline/fragments/hero.html
··· 1 + {{ define "timeline/fragments/hero" }} 2 + <div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 + <div class="flex flex-col gap-6"> 4 + <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 + 6 + <p class="text-lg"> 7 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 + </p> 9 + <p class="text-lg"> 10 + we envision a place where developers have complete ownership of their 11 + code, open source communities can freely self-govern and most 12 + importantly, coding can be social and fun again. 13 + </p> 14 + 15 + <div class="flex gap-6 items-center"> 16 + <a href="/signup" class="no-underline hover:no-underline "> 17 + <button class="btn-create flex gap-2 px-4 items-center"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </button> 20 + </a> 21 + </div> 22 + </div> 23 + 24 + <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="block"> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 + </a> 28 + <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 + Monorepo for Tangled, built in the open with the community. 30 + </figcaption> 31 + </figure> 32 + </div> 33 + {{ end }} 34 +
+116
appview/pages/templates/timeline/fragments/timeline.html
··· 1 + {{ define "timeline/fragments/timeline" }} 2 + <div class="py-4"> 3 + <div class="px-6 pb-4"> 4 + <p class="text-xl font-bold dark:text-white">Timeline</p> 5 + </div> 6 + 7 + <div class="flex flex-col gap-4"> 8 + {{ range $i, $e := .Timeline }} 9 + <div class="relative"> 10 + {{ if ne $i 0 }} 11 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 12 + {{ end }} 13 + {{ with $e }} 14 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 + {{ if .Repo }} 16 + {{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }} 17 + {{ else if .Star }} 18 + {{ template "timeline/fragments/starEvent" (list $ .Star) }} 19 + {{ else if .Follow }} 20 + {{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }} 21 + {{ end }} 22 + </div> 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + {{ define "timeline/fragments/repoEvent" }} 31 + {{ $root := index . 0 }} 32 + {{ $repo := index . 1 }} 33 + {{ $source := index . 2 }} 34 + {{ $userHandle := resolve $repo.Did }} 35 + <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"> 36 + {{ template "user/fragments/picHandleLink" $repo.Did }} 37 + {{ with $source }} 38 + {{ $sourceDid := resolve .Did }} 39 + forked 40 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 41 + {{ $sourceDid }}/{{ .Name }} 42 + </a> 43 + to 44 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 45 + {{ else }} 46 + created 47 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 48 + {{ $repo.Name }} 49 + </a> 50 + {{ end }} 51 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 52 + </div> 53 + {{ with $repo }} 54 + {{ template "user/fragments/repoCard" (list $root . true) }} 55 + {{ end }} 56 + {{ end }} 57 + 58 + {{ define "timeline/fragments/starEvent" }} 59 + {{ $root := index . 0 }} 60 + {{ $star := index . 1 }} 61 + {{ with $star }} 62 + {{ $starrerHandle := resolve .StarredByDid }} 63 + {{ $repoOwnerHandle := resolve .Repo.Did }} 64 + <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"> 65 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 66 + starred 67 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 68 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 69 + </a> 70 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 71 + </div> 72 + {{ with .Repo }} 73 + {{ template "user/fragments/repoCard" (list $root . true) }} 74 + {{ end }} 75 + {{ end }} 76 + {{ end }} 77 + 78 + {{ define "timeline/fragments/followEvent" }} 79 + {{ $root := index . 0 }} 80 + {{ $follow := index . 1 }} 81 + {{ $profile := index . 2 }} 82 + {{ $stat := index . 3 }} 83 + 84 + {{ $userHandle := resolve $follow.UserDid }} 85 + {{ $subjectHandle := resolve $follow.SubjectDid }} 86 + <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"> 87 + {{ template "user/fragments/picHandleLink" $userHandle }} 88 + followed 89 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 90 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 91 + </div> 92 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 93 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 94 + <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 95 + </div> 96 + 97 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 98 + <a href="/{{ $subjectHandle }}"> 99 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 100 + </a> 101 + {{ with $profile }} 102 + {{ with .Description }} 103 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 104 + {{ end }} 105 + {{ end }} 106 + {{ with $stat }} 107 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 108 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 109 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 110 + <span class="select-none after:content-['ยท']"></span> 111 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 112 + </div> 113 + {{ end }} 114 + </div> 115 + </div> 116 + {{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
··· 1 + {{ define "timeline/fragments/trending" }} 2 + <div class="w-full md:mx-0 py-4"> 3 + <div class="px-6 pb-4"> 4 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 5 + Trending 6 + {{ i "trending-up" "size-4 flex-shrink-0" }} 7 + </h3> 8 + </div> 9 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 10 + {{ range $index, $repo := .Repos }} 11 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 12 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 13 + </div> 14 + {{ else }} 15 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 17 + No trending repositories this week 18 + </div> 19 + </div> 20 + {{ end }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + 25 +
+90
appview/pages/templates/timeline/home.html
··· 1 + {{ define "title" }}tangled &middot; tightly-knit social coding{{ 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 + 11 + {{ define "content" }} 12 + <div class="flex flex-col gap-4"> 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ template "features" . }} 15 + {{ template "timeline/fragments/trending" . }} 16 + {{ template "timeline/fragments/timeline" . }} 17 + <div class="flex justify-end"> 18 + <a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400"> 19 + view more 20 + {{ i "arrow-right" "size-4" }} 21 + </a> 22 + </div> 23 + </div> 24 + {{ end }} 25 + 26 + 27 + {{ define "feature" }} 28 + {{ $info := index . 0 }} 29 + {{ $bullets := index . 1 }} 30 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 31 + <div class="flex-1"> 32 + <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 33 + <ul class="leading-normal"> 34 + {{ range $bullets }} 35 + <li><p>{{ escapeHtml . }}</p></li> 36 + {{ end }} 37 + </ul> 38 + </div> 39 + <div class="flex-shrink-0 w-96 md:w-1/3"> 40 + <a href="{{ $info.image }}"> 41 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 42 + </a> 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "features" }} 48 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 49 + {{ template "feature" (list 50 + (dict 51 + "title" "lightweight git repo hosting" 52 + "image" "https://assets.tangled.network/what-is-tangled-repo.png" 53 + "alt" "A repository hosted on Tangled" 54 + ) 55 + (list 56 + "Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations." 57 + "Add friends to your knot or invite collaborators to your repository." 58 + "Guarded by fine-grained role-based access control." 59 + "Use SSH to push and pull." 60 + ) 61 + ) }} 62 + 63 + {{ template "feature" (list 64 + (dict 65 + "title" "improved pull request model" 66 + "image" "https://assets.tangled.network/pulls.png" 67 + "alt" "Round-based pull requests." 68 + ) 69 + (list 70 + "An intuitive and effective round-based pull request flow, with inter-diffing between rounds." 71 + "Stacked pull requests using Jujutsu's change IDs." 72 + "Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes." 73 + ) 74 + ) }} 75 + 76 + {{ template "feature" (list 77 + (dict 78 + "title" "run pipelines using spindles" 79 + "image" "https://assets.tangled.network/pipelines.png" 80 + "alt" "CI pipeline running on spindle" 81 + ) 82 + (list 83 + "Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners." 84 + "Natively supports Nix for package management." 85 + "Easily extended to support different execution backends." 86 + ) 87 + ) }} 88 + </div> 89 + {{ end }} 90 +
+18
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 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 + 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 18 + {{ end }}
-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
··· 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
··· 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
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+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
··· 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
··· 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 }}
+1 -1
appview/pages/templates/user/fragments/picHandle.html
··· 1 1 {{ define "user/fragments/picHandle" }} 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 - alt="{{ . }}" 4 + alt="" 5 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 6 /> 7 7 {{ . | truncateAt30 }}
+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
··· 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
+39 -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 + {{ template "repo/fragments/languageBall" . }} 40 + <span>{{ . }}</span> 41 + </div> 35 42 {{ end }} 36 43 {{ 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> 44 + <div class="flex gap-1 items-center text-sm"> 45 + {{ i "star" "w-3 h-3 fill-current" }} 46 + <span>{{ . }}</span> 47 + </div> 41 48 {{ end }} 42 49 {{ 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> 50 + <div class="flex gap-1 items-center text-sm"> 51 + {{ i "circle-dot" "w-3 h-3" }} 52 + <span>{{ . }}</span> 53 + </div> 47 54 {{ end }} 48 55 {{ 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> 56 + <div class="flex gap-1 items-center text-sm"> 57 + {{ i "git-pull-request" "w-3 h-3" }} 58 + <span>{{ . }}</span> 59 + </div> 53 60 {{ end }} 54 61 </div> 55 62 {{ end }} 56 - 57 -
+1
appview/pages/templates/user/login.html
··· 41 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 42 </span> 43 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 44 45 45 46 <button 46 47 class="btn w-full my-2 mt-6 text-base "
+269
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 justify-between gap-2"> 60 + <span class="flex items-center gap-2"> 61 + <span class="text-gray-500 dark:text-gray-400"> 62 + {{ if .Source }} 63 + {{ i "git-fork" "w-4 h-4" }} 64 + {{ else }} 65 + {{ i "book-plus" "w-4 h-4" }} 66 + {{ end }} 67 + </span> 68 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 69 + {{- .Repo.Name -}} 70 + </a> 71 + </span> 72 + 73 + {{ with .Repo.RepoStats }} 74 + {{ with .Language }} 75 + <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/languageBall" . }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{end }} 80 + {{end }} 81 + </div> 82 + {{ end }} 83 + </div> 84 + </details> 85 + {{ end }} 86 + {{ end }} 87 + 88 + {{ define "issueEvents" }} 89 + {{ $items := .Items }} 90 + {{ $stats := .Stats }} 91 + 92 + {{ if gt (len $items) 0 }} 93 + <details> 94 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 95 + <div class="flex flex-wrap items-center gap-2"> 96 + {{ i "circle-dot" "w-4 h-4" }} 97 + 98 + <div> 99 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 100 + </div> 101 + 102 + {{ if gt $stats.Open 0 }} 103 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 104 + {{$stats.Open}} open 105 + </span> 106 + {{ end }} 107 + 108 + {{ if gt $stats.Closed 0 }} 109 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 110 + {{$stats.Closed}} closed 111 + </span> 112 + {{ end }} 113 + 114 + </div> 115 + </summary> 116 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 117 + {{ range $items }} 118 + {{ $repoOwner := resolve .Repo.Did }} 119 + {{ $repoName := .Repo.Name }} 120 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 121 + 122 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 123 + {{ if .Open }} 124 + <span class="text-green-600 dark:text-green-500"> 125 + {{ i "circle-dot" "w-4 h-4" }} 126 + </span> 127 + {{ else }} 128 + <span class="text-gray-500 dark:text-gray-400"> 129 + {{ i "ban" "w-4 h-4" }} 130 + </span> 131 + {{ end }} 132 + <div class="flex-none min-w-8 text-right"> 133 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 134 + </div> 135 + <div class="break-words max-w-full"> 136 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 137 + {{ .Title -}} 138 + </a> 139 + on 140 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 141 + {{$repoUrl}} 142 + </a> 143 + </div> 144 + </div> 145 + {{ end }} 146 + </div> 147 + </details> 148 + {{ end }} 149 + {{ end }} 150 + 151 + {{ define "pullEvents" }} 152 + {{ $items := .Items }} 153 + {{ $stats := .Stats }} 154 + {{ if gt (len $items) 0 }} 155 + <details> 156 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 157 + <div class="flex flex-wrap items-center gap-2"> 158 + {{ i "git-pull-request" "w-4 h-4" }} 159 + 160 + <div> 161 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 162 + </div> 163 + 164 + {{ if gt $stats.Open 0 }} 165 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 166 + {{$stats.Open}} open 167 + </span> 168 + {{ end }} 169 + 170 + {{ if gt $stats.Merged 0 }} 171 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 172 + {{$stats.Merged}} merged 173 + </span> 174 + {{ end }} 175 + 176 + 177 + {{ if gt $stats.Closed 0 }} 178 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 179 + {{$stats.Closed}} closed 180 + </span> 181 + {{ end }} 182 + 183 + </div> 184 + </summary> 185 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 186 + {{ range $items }} 187 + {{ $repoOwner := resolve .Repo.Did }} 188 + {{ $repoName := .Repo.Name }} 189 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 190 + 191 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 192 + {{ if .State.IsOpen }} 193 + <span class="text-green-600 dark:text-green-500"> 194 + {{ i "git-pull-request" "w-4 h-4" }} 195 + </span> 196 + {{ else if .State.IsMerged }} 197 + <span class="text-purple-600 dark:text-purple-500"> 198 + {{ i "git-merge" "w-4 h-4" }} 199 + </span> 200 + {{ else }} 201 + <span class="text-gray-600 dark:text-gray-300"> 202 + {{ i "git-pull-request-closed" "w-4 h-4" }} 203 + </span> 204 + {{ end }} 205 + <div class="flex-none min-w-8 text-right"> 206 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 207 + </div> 208 + <div class="break-words max-w-full"> 209 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 210 + {{ .Title -}} 211 + </a> 212 + on 213 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 214 + {{$repoUrl}} 215 + </a> 216 + </div> 217 + </div> 218 + {{ end }} 219 + </div> 220 + </details> 221 + {{ end }} 222 + {{ end }} 223 + 224 + {{ define "ownRepos" }} 225 + <div> 226 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 228 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 + <span>PINNED REPOS</span> 230 + </a> 231 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 232 + <button 233 + hx-get="profile/edit-pins" 234 + hx-target="#all-repos" 235 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 236 + {{ i "pencil" "w-3 h-3" }} 237 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 238 + </button> 239 + {{ end }} 240 + </div> 241 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 242 + {{ range .Repos }} 243 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 244 + {{ template "user/fragments/repoCard" (list $ . false) }} 245 + </div> 246 + {{ else }} 247 + <p class="dark:text-white">This user does not have any pinned repos.</p> 248 + {{ end }} 249 + </div> 250 + </div> 251 + {{ end }} 252 + 253 + {{ define "collaboratingRepos" }} 254 + {{ if gt (len .CollaboratingRepos) 0 }} 255 + <div> 256 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 257 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 258 + {{ range .CollaboratingRepos }} 259 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 260 + {{ template "user/fragments/repoCard" (list $ . true) }} 261 + </div> 262 + {{ else }} 263 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 264 + {{ end }} 265 + </div> 266 + </div> 267 + {{ end }} 268 + {{ end }} 269 +
-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
··· 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
··· 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 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-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
··· 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
··· 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 }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <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 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+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 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-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
··· 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 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-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
··· 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
··· 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
··· 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 }}
+1 -1
appview/posthog/notifier.go
··· 58 58 59 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 60 err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 61 + DistinctId: issue.Did, 62 62 Event: "new_issue", 63 63 Properties: posthog.Properties{ 64 64 "repo_at": issue.RepoAt.String(),
+389 -270
appview/pulls/pulls.go
··· 5 5 "encoding/json" 6 6 "errors" 7 7 "fmt" 8 - "io" 9 8 "log" 10 9 "net/http" 11 10 "sort" ··· 19 18 "tangled.sh/tangled.sh/core/appview/notify" 20 19 "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 24 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" 26 26 "tangled.sh/tangled.sh/core/tid" 27 27 "tangled.sh/tangled.sh/core/types" 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 - resubmitResult = s.resubmitCheck(f, pull, stack) 102 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 103 } 104 104 105 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 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 - resubmitResult = s.resubmitCheck(f, pull, stack) 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 168 158 } 169 159 170 160 repoInfo := f.RepoInfo(user) ··· 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 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 285 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 300 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 301 287 return pages.Unknown 302 288 } ··· 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 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 325 - if err != nil { 326 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 327 - return pages.Unknown 310 + scheme := "http" 311 + if !s.config.Core.Dev { 312 + scheme = "https" 313 + } 314 + host := fmt.Sprintf("%s://%s", scheme, knot) 315 + xrpcc := &indigoxrpc.Client{ 316 + Host: host, 328 317 } 329 318 330 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 319 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 320 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 331 321 if err != nil { 322 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 323 + log.Println("failed to call XRPC repo.branches", xrpcerr) 324 + return pages.Unknown 325 + } 332 326 log.Println("failed to reach knotserver", err) 333 327 return pages.Unknown 334 328 } 335 329 330 + targetBranch := branchResp 331 + 336 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 337 333 338 334 if pull.IsStacked() && stack != nil { ··· 340 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 341 337 } 342 338 343 - if latestSourceRev != result.Branch.Hash { 339 + if latestSourceRev != targetBranch.Hash { 344 340 return pages.ShouldResubmit 345 341 } 346 342 ··· 377 373 return 378 374 } 379 375 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 376 patch := pull.Submissions[roundIdInt].Patch 392 377 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 393 378 394 379 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 395 380 LoggedInUser: user, 396 - DidHandleMap: didHandleMap, 397 381 RepoInfo: f.RepoInfo(user), 398 382 Pull: pull, 399 383 Stack: stack, ··· 440 424 return 441 425 } 442 426 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 427 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 455 428 if err != nil { 456 429 log.Println("failed to interdiff; current patch malformed") ··· 472 445 RepoInfo: f.RepoInfo(user), 473 446 Pull: pull, 474 447 Round: roundIdInt, 475 - DidHandleMap: didHandleMap, 476 448 Interdiff: interdiff, 477 449 DiffOpts: diffOpts, 478 450 }) ··· 494 466 return 495 467 } 496 468 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 469 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 509 470 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 510 471 } ··· 529 490 530 491 pulls, err := db.GetPulls( 531 492 s.db, 532 - db.FilterEq("repo_at", f.RepoAt), 493 + db.FilterEq("repo_at", f.RepoAt()), 533 494 db.FilterEq("state", state), 534 495 ) 535 496 if err != nil { ··· 595 556 m[p.Sha] = p 596 557 } 597 558 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 559 s.pages.RepoPulls(w, pages.RepoPullsParams{ 613 560 LoggedInUser: s.oauth.GetUser(r), 614 561 RepoInfo: f.RepoInfo(user), 615 562 Pulls: pulls, 616 - DidHandleMap: didHandleMap, 617 563 FilteringBy: state, 618 564 Stacks: stacks, 619 565 Pipelines: m, ··· 669 615 defer tx.Rollback() 670 616 671 617 createdAt := time.Now().Format(time.RFC3339) 672 - ownerDid := user.Did 673 618 674 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 619 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 675 620 if err != nil { 676 621 log.Println("failed to get pull at", err) 677 622 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 678 623 return 679 624 } 680 625 681 - atUri := f.RepoAt.String() 682 626 client, err := s.oauth.AuthorizedClient(r) 683 627 if err != nil { 684 628 log.Println("failed to get authorized client", err) ··· 691 635 Rkey: tid.TID(), 692 636 Record: &lexutil.LexiconTypeDecoder{ 693 637 Val: &tangled.RepoPullComment{ 694 - Repo: &atUri, 695 638 Pull: string(pullAt), 696 - Owner: &ownerDid, 697 639 Body: body, 698 640 CreatedAt: createdAt, 699 641 }, ··· 707 649 708 650 comment := &db.PullComment{ 709 651 OwnerDid: user.Did, 710 - RepoAt: f.RepoAt.String(), 652 + RepoAt: f.RepoAt().String(), 711 653 PullId: pull.PullId, 712 654 Body: body, 713 655 CommentAt: atResp.Uri, ··· 746 688 747 689 switch r.Method { 748 690 case http.MethodGet: 749 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 691 + scheme := "http" 692 + if !s.config.Core.Dev { 693 + scheme = "https" 694 + } 695 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 696 + xrpcc := &indigoxrpc.Client{ 697 + Host: host, 698 + } 699 + 700 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 701 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 750 702 if err != nil { 751 - log.Printf("failed to create unsigned client for %s", f.Knot) 752 - s.pages.Error503(w) 703 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 704 + log.Println("failed to call XRPC repo.branches", xrpcerr) 705 + s.pages.Error503(w) 706 + return 707 + } 708 + log.Println("failed to fetch branches", err) 753 709 return 754 710 } 755 711 756 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 757 - if err != nil { 758 - log.Println("failed to fetch branches", err) 712 + var result types.RepoBranchesResponse 713 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 714 + log.Println("failed to decode XRPC response", err) 715 + s.pages.Error503(w) 759 716 return 760 717 } 761 718 ··· 801 758 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 802 759 return 803 760 } 761 + sanitizer := markup.NewSanitizer() 762 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 763 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 764 + return 765 + } 804 766 } 805 767 806 768 // Validate we have at least one valid PR creation method ··· 815 777 return 816 778 } 817 779 818 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 819 - if err != nil { 820 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 821 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 822 - return 780 + // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 781 + // if err != nil { 782 + // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 783 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 784 + // return 785 + // } 786 + 787 + // TODO: make capabilities an xrpc call 788 + caps := struct { 789 + PullRequests struct { 790 + FormatPatch bool 791 + BranchSubmissions bool 792 + ForkSubmissions bool 793 + PatchSubmissions bool 794 + } 795 + }{ 796 + PullRequests: struct { 797 + FormatPatch bool 798 + BranchSubmissions bool 799 + ForkSubmissions bool 800 + PatchSubmissions bool 801 + }{ 802 + FormatPatch: true, 803 + BranchSubmissions: true, 804 + ForkSubmissions: true, 805 + PatchSubmissions: true, 806 + }, 823 807 } 824 808 825 - caps, err := us.Capabilities() 826 - if err != nil { 827 - log.Println("error fetching knot caps", f.Knot, err) 828 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 829 - return 830 - } 809 + // caps, err := us.Capabilities() 810 + // if err != nil { 811 + // log.Println("error fetching knot caps", f.Knot, err) 812 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 813 + // return 814 + // } 831 815 832 816 if !caps.PullRequests.FormatPatch { 833 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 869 853 sourceBranch string, 870 854 isStacked bool, 871 855 ) { 872 - // Generate a patch using /compare 873 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 874 - if err != nil { 875 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 876 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 877 - return 856 + scheme := "http" 857 + if !s.config.Core.Dev { 858 + scheme = "https" 859 + } 860 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 861 + xrpcc := &indigoxrpc.Client{ 862 + Host: host, 878 863 } 879 864 880 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 881 867 if err != nil { 868 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 869 + log.Println("failed to call XRPC repo.compare", xrpcerr) 870 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 871 + return 872 + } 882 873 log.Println("failed to compare", err) 883 874 s.pages.Notice(w, "pull", err.Error()) 884 875 return 885 876 } 886 877 878 + var comparison types.RepoFormatPatchResponse 879 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 880 + log.Println("failed to decode XRPC compare response", err) 881 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 882 + return 883 + } 884 + 887 885 sourceRev := comparison.Rev2 888 886 patch := comparison.Patch 889 887 ··· 913 911 } 914 912 915 913 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) 914 + repoString := strings.SplitN(forkRepo, "/", 2) 915 + forkOwnerDid := repoString[0] 916 + repoName := repoString[1] 917 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 917 918 if errors.Is(err, sql.ErrNoRows) { 918 919 s.pages.Notice(w, "pull", "No such fork.") 919 920 return ··· 923 924 return 924 925 } 925 926 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 - } 927 + client, err := s.oauth.ServiceClient( 928 + r, 929 + oauth.WithService(fork.Knot), 930 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 931 + oauth.WithDev(s.config.Core.Dev), 932 + ) 932 933 933 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 934 - if err != nil { 935 - log.Println("failed to create signed client:", err) 936 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 934 + resp, err := tangled.RepoHiddenRef( 935 + r.Context(), 936 + client, 937 + &tangled.RepoHiddenRef_Input{ 938 + ForkRef: sourceBranch, 939 + RemoteRef: targetBranch, 940 + Repo: fork.RepoAt().String(), 941 + }, 942 + ) 943 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 944 + s.pages.Notice(w, "pull", err.Error()) 937 945 return 938 946 } 939 947 940 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 941 - if err != nil { 942 - log.Println("failed to create unsigned client:", err) 943 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 944 - return 945 - } 946 - 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.") 951 - return 952 - } 953 - 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.") 948 + if !resp.Success { 949 + errorMsg := "Failed to create pull request" 950 + if resp.Error != nil { 951 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 952 + } 953 + s.pages.Notice(w, "pull", errorMsg) 958 954 return 959 955 } 960 956 ··· 964 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 965 961 // targetBranch: main (on repo-1) 966 962 // sourceBranch: feature-1 (on repo-fork) 967 - comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 963 + forkScheme := "http" 964 + if !s.config.Core.Dev { 965 + forkScheme = "https" 966 + } 967 + forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 968 + forkXrpcc := &indigoxrpc.Client{ 969 + Host: forkHost, 970 + } 971 + 972 + forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 973 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 968 974 if err != nil { 975 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 976 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 977 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 978 + return 979 + } 969 980 log.Println("failed to compare across branches", err) 970 981 s.pages.Notice(w, "pull", err.Error()) 982 + return 983 + } 984 + 985 + var comparison types.RepoFormatPatchResponse 986 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 987 + log.Println("failed to decode XRPC compare response for fork", err) 988 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 971 989 return 972 990 } 973 991 ··· 979 997 return 980 998 } 981 999 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 - } 1000 + forkAtUri := fork.RepoAt() 1001 + forkAtUriStr := forkAtUri.String() 988 1002 989 1003 pullSource := &db.PullSource{ 990 1004 Branch: sourceBranch, ··· 992 1006 } 993 1007 recordPullSource := &tangled.RepoPull_Source{ 994 1008 Branch: sourceBranch, 995 - Repo: &fork.AtUri, 1009 + Repo: &forkAtUriStr, 996 1010 Sha: sourceRev, 997 1011 } 998 1012 ··· 1068 1082 Body: body, 1069 1083 TargetBranch: targetBranch, 1070 1084 OwnerDid: user.Did, 1071 - RepoAt: f.RepoAt, 1085 + RepoAt: f.RepoAt(), 1072 1086 Rkey: rkey, 1073 1087 Submissions: []*db.PullSubmission{ 1074 1088 &initialSubmission, ··· 1081 1095 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1082 1096 return 1083 1097 } 1084 - pullId, err := db.NextPullId(tx, f.RepoAt) 1098 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1085 1099 if err != nil { 1086 1100 log.Println("failed to get pull id", err) 1087 1101 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1094 1108 Rkey: rkey, 1095 1109 Record: &lexutil.LexiconTypeDecoder{ 1096 1110 Val: &tangled.RepoPull{ 1097 - Title: title, 1098 - PullId: int64(pullId), 1099 - TargetRepo: string(f.RepoAt), 1100 - TargetBranch: targetBranch, 1101 - Patch: patch, 1102 - Source: recordPullSource, 1111 + Title: title, 1112 + Target: &tangled.RepoPull_Target{ 1113 + Repo: string(f.RepoAt()), 1114 + Branch: targetBranch, 1115 + }, 1116 + Patch: patch, 1117 + Source: recordPullSource, 1103 1118 }, 1104 1119 }, 1105 1120 }) ··· 1267 1282 return 1268 1283 } 1269 1284 1270 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1285 + scheme := "http" 1286 + if !s.config.Core.Dev { 1287 + scheme = "https" 1288 + } 1289 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1290 + xrpcc := &indigoxrpc.Client{ 1291 + Host: host, 1292 + } 1293 + 1294 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1295 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1271 1296 if err != nil { 1272 - log.Printf("failed to create unsigned client for %s", f.Knot) 1273 - s.pages.Error503(w) 1297 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1298 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1299 + s.pages.Error503(w) 1300 + return 1301 + } 1302 + log.Println("failed to fetch branches", err) 1274 1303 return 1275 1304 } 1276 1305 1277 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1278 - if err != nil { 1279 - log.Println("failed to reach knotserver", err) 1306 + var result types.RepoBranchesResponse 1307 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1308 + log.Println("failed to decode XRPC response", err) 1309 + s.pages.Error503(w) 1280 1310 return 1281 1311 } 1282 1312 ··· 1330 1360 } 1331 1361 1332 1362 forkVal := r.URL.Query().Get("fork") 1333 - 1363 + repoString := strings.SplitN(forkVal, "/", 2) 1364 + forkOwnerDid := repoString[0] 1365 + forkName := repoString[1] 1334 1366 // fork repo 1335 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1367 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1336 1368 if err != nil { 1337 1369 log.Println("failed to get repo", user.Did, forkVal) 1338 1370 return 1339 1371 } 1340 1372 1341 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1342 - if err != nil { 1343 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1344 - s.pages.Error503(w) 1345 - return 1373 + sourceScheme := "http" 1374 + if !s.config.Core.Dev { 1375 + sourceScheme = "https" 1376 + } 1377 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1378 + sourceXrpcc := &indigoxrpc.Client{ 1379 + Host: sourceHost, 1346 1380 } 1347 1381 1348 - sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1382 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1383 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1349 1384 if err != nil { 1350 - log.Println("failed to reach knotserver for source branches", err) 1385 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1386 + log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1387 + s.pages.Error503(w) 1388 + return 1389 + } 1390 + log.Println("failed to fetch source branches", err) 1351 1391 return 1352 1392 } 1353 1393 1354 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1355 - if err != nil { 1356 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1394 + // Decode source branches 1395 + var sourceBranches types.RepoBranchesResponse 1396 + if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1397 + log.Println("failed to decode source branches XRPC response", err) 1357 1398 s.pages.Error503(w) 1358 1399 return 1359 1400 } 1360 1401 1361 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1402 + targetScheme := "http" 1403 + if !s.config.Core.Dev { 1404 + targetScheme = "https" 1405 + } 1406 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1407 + targetXrpcc := &indigoxrpc.Client{ 1408 + Host: targetHost, 1409 + } 1410 + 1411 + targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1412 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1362 1413 if err != nil { 1363 - log.Println("failed to reach knotserver for target branches", err) 1414 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1415 + log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1416 + s.pages.Error503(w) 1417 + return 1418 + } 1419 + log.Println("failed to fetch target branches", err) 1364 1420 return 1365 1421 } 1366 1422 1367 - sourceBranches := sourceResult.Branches 1368 - sort.Slice(sourceBranches, func(i int, j int) bool { 1369 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1423 + // Decode target branches 1424 + var targetBranches types.RepoBranchesResponse 1425 + if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1426 + log.Println("failed to decode target branches XRPC response", err) 1427 + s.pages.Error503(w) 1428 + return 1429 + } 1430 + 1431 + sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1432 + return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1370 1433 }) 1371 1434 1372 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1373 1436 RepoInfo: f.RepoInfo(user), 1374 - SourceBranches: sourceBranches, 1375 - TargetBranches: targetResult.Branches, 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1376 1439 }) 1377 1440 } 1378 1441 ··· 1467 1530 return 1468 1531 } 1469 1532 1470 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1471 - if err != nil { 1472 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1473 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1474 - return 1533 + scheme := "http" 1534 + if !s.config.Core.Dev { 1535 + scheme = "https" 1536 + } 1537 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1538 + xrpcc := &indigoxrpc.Client{ 1539 + Host: host, 1475 1540 } 1476 1541 1477 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1542 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1543 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1478 1544 if err != nil { 1545 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1546 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1547 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1548 + return 1549 + } 1479 1550 log.Printf("compare request failed: %s", err) 1480 1551 s.pages.Notice(w, "resubmit-error", err.Error()) 1552 + return 1553 + } 1554 + 1555 + var comparison types.RepoFormatPatchResponse 1556 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1557 + log.Println("failed to decode XRPC compare response", err) 1558 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1481 1559 return 1482 1560 } 1483 1561 ··· 1517 1595 } 1518 1596 1519 1597 // extract patch by performing compare 1520 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1598 + forkScheme := "http" 1599 + if !s.config.Core.Dev { 1600 + forkScheme = "https" 1601 + } 1602 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1603 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1604 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1521 1605 if err != nil { 1522 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1606 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1607 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1608 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1609 + return 1610 + } 1611 + log.Printf("failed to compare branches: %s", err) 1523 1612 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1524 1613 return 1525 1614 } 1526 1615 1527 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1528 - if err != nil { 1529 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1616 + var forkComparison types.RepoFormatPatchResponse 1617 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1618 + log.Println("failed to decode XRPC compare response for fork", err) 1530 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1531 1620 return 1532 1621 } 1533 1622 1534 1623 // update the hidden tracking branch to latest 1535 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1624 + client, err := s.oauth.ServiceClient( 1625 + r, 1626 + oauth.WithService(forkRepo.Knot), 1627 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1628 + oauth.WithDev(s.config.Core.Dev), 1629 + ) 1536 1630 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.") 1631 + log.Printf("failed to connect to knot server: %v", err) 1539 1632 return 1540 1633 } 1541 1634 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.") 1635 + resp, err := tangled.RepoHiddenRef( 1636 + r.Context(), 1637 + client, 1638 + &tangled.RepoHiddenRef_Input{ 1639 + ForkRef: pull.PullSource.Branch, 1640 + RemoteRef: pull.TargetBranch, 1641 + Repo: forkRepo.RepoAt().String(), 1642 + }, 1643 + ) 1644 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1645 + s.pages.Notice(w, "resubmit-error", err.Error()) 1546 1646 return 1547 1647 } 1548 - 1549 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1550 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1551 - if err != nil { 1552 - log.Printf("failed to compare branches: %s", err) 1553 - s.pages.Notice(w, "resubmit-error", err.Error()) 1648 + if !resp.Success { 1649 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1650 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1554 1651 return 1555 1652 } 1653 + 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1556 1656 1557 1657 sourceRev := comparison.Rev2 1558 1658 patch := comparison.Patch ··· 1656 1756 SwapRecord: ex.Cid, 1657 1757 Record: &lexutil.LexiconTypeDecoder{ 1658 1758 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, 1759 + Title: pull.Title, 1760 + Target: &tangled.RepoPull_Target{ 1761 + Repo: string(f.RepoAt()), 1762 + Branch: pull.TargetBranch, 1763 + }, 1764 + Patch: patch, // new patch 1765 + Source: recordPullSource, 1665 1766 }, 1666 1767 }, 1667 1768 }) ··· 1774 1875 1775 1876 // deleted pulls are marked as deleted in the DB 1776 1877 for _, p := range deletions { 1878 + // do not do delete already merged PRs 1879 + if p.State == db.PullMerged { 1880 + continue 1881 + } 1882 + 1777 1883 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1778 1884 if err != nil { 1779 1885 log.Println("failed to delete pull", err, p.PullId) ··· 1813 1919 for id := range updated { 1814 1920 op, _ := origById[id] 1815 1921 np, _ := newById[id] 1922 + 1923 + // do not update already merged PRs 1924 + if op.State == db.PullMerged { 1925 + continue 1926 + } 1816 1927 1817 1928 submission := np.Submissions[np.LastRoundNumber()] 1818 1929 ··· 1958 2069 1959 2070 patch := pullsToMerge.CombinedPatch() 1960 2071 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 2072 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1969 2073 if err != nil { 1970 2074 log.Printf("resolving identity: %s", err) ··· 1977 2081 log.Printf("failed to get primary email: %s", err) 1978 2082 } 1979 2083 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 2084 + authorName := ident.Handle.String() 2085 + mergeInput := &tangled.RepoMerge_Input{ 2086 + Did: f.OwnerDid(), 2087 + Name: f.Name, 2088 + Branch: pull.TargetBranch, 2089 + Patch: patch, 2090 + CommitMessage: &pull.Title, 2091 + AuthorName: &authorName, 1985 2092 } 1986 2093 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) 2094 + if pull.Body != "" { 2095 + mergeInput.CommitBody = &pull.Body 2096 + } 2097 + 2098 + if email.Address != "" { 2099 + mergeInput.AuthorEmail = &email.Address 2100 + } 2101 + 2102 + client, err := s.oauth.ServiceClient( 2103 + r, 2104 + oauth.WithService(f.Knot), 2105 + oauth.WithLxm(tangled.RepoMergeNSID), 2106 + oauth.WithDev(s.config.Core.Dev), 2107 + ) 1989 2108 if err != nil { 1990 - log.Printf("failed to merge pull request: %s", err) 2109 + log.Printf("failed to connect to knot server: %v", err) 1991 2110 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1992 2111 return 1993 2112 } 1994 2113 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.") 2114 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 2115 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 2116 + s.pages.Notice(w, "pull-merge-error", err.Error()) 1998 2117 return 1999 2118 } 2000 2119 ··· 2007 2126 defer tx.Rollback() 2008 2127 2009 2128 for _, p := range pullsToMerge { 2010 - err := db.MergePull(tx, f.RepoAt, p.PullId) 2129 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 2011 2130 if err != nil { 2012 2131 log.Printf("failed to update pull request status in database: %s", err) 2013 2132 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2023 2142 return 2024 2143 } 2025 2144 2026 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 2145 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2027 2146 } 2028 2147 2029 2148 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2075 2194 2076 2195 for _, p := range pullsToClose { 2077 2196 // Close the pull in the database 2078 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2197 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2079 2198 if err != nil { 2080 2199 log.Println("failed to close pull", err) 2081 2200 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2143 2262 2144 2263 for _, p := range pullsToReopen { 2145 2264 // Close the pull in the database 2146 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2265 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2147 2266 if err != nil { 2148 2267 log.Println("failed to close pull", err) 2149 2268 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2195 2314 Body: body, 2196 2315 TargetBranch: targetBranch, 2197 2316 OwnerDid: user.Did, 2198 - RepoAt: f.RepoAt, 2317 + RepoAt: f.RepoAt(), 2199 2318 Rkey: rkey, 2200 2319 Submissions: []*db.PullSubmission{ 2201 2320 &initialSubmission,
+31 -13
appview/repo/artifact.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 5 + "encoding/json" 4 6 "fmt" 5 7 "log" 6 8 "net/http" ··· 9 11 10 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 15 "github.com/dustin/go-humanize" 13 16 "github.com/go-chi/chi/v5" 14 17 "github.com/go-git/go-git/v5/plumbing" ··· 17 20 "tangled.sh/tangled.sh/core/appview/db" 18 21 "tangled.sh/tangled.sh/core/appview/pages" 19 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/knotclient" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 21 24 "tangled.sh/tangled.sh/core/tid" 22 25 "tangled.sh/tangled.sh/core/types" 23 26 ) ··· 33 36 return 34 37 } 35 38 36 - tag, err := rp.resolveTag(f, tagParam) 39 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 37 40 if err != nil { 38 41 log.Println("failed to resolve tag", err) 39 42 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 76 79 Artifact: uploadBlobResp.Blob, 77 80 CreatedAt: createdAt.Format(time.RFC3339), 78 81 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 82 + Repo: f.RepoAt().String(), 80 83 Tag: tag.Tag.Hash[:], 81 84 }, 82 85 }, ··· 100 103 artifact := db.Artifact{ 101 104 Did: user.Did, 102 105 Rkey: rkey, 103 - RepoAt: f.RepoAt, 106 + RepoAt: f.RepoAt(), 104 107 Tag: tag.Tag.Hash, 105 108 CreatedAt: createdAt, 106 109 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 140 143 return 141 144 } 142 145 143 - tag, err := rp.resolveTag(f, tagParam) 146 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 144 147 if err != nil { 145 148 log.Println("failed to resolve tag", err) 146 149 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 155 158 156 159 artifacts, err := db.GetArtifact( 157 160 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 161 + db.FilterEq("repo_at", f.RepoAt()), 159 162 db.FilterEq("tag", tag.Tag.Hash[:]), 160 163 db.FilterEq("name", filename), 161 164 ) ··· 197 200 198 201 artifacts, err := db.GetArtifact( 199 202 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 203 + db.FilterEq("repo_at", f.RepoAt()), 201 204 db.FilterEq("tag", tag[:]), 202 205 db.FilterEq("name", filename), 203 206 ) ··· 239 242 defer tx.Rollback() 240 243 241 244 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 245 + db.FilterEq("repo_at", f.RepoAt()), 243 246 db.FilterEq("tag", artifact.Tag[:]), 244 247 db.FilterEq("name", filename), 245 248 ) ··· 259 262 w.Write([]byte{}) 260 263 } 261 264 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 265 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 266 tagParam, err := url.QueryUnescape(tagParam) 264 267 if err != nil { 265 268 return nil, err 266 269 } 267 270 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 - if err != nil { 270 - return nil, err 271 + scheme := "http" 272 + if !rp.config.Core.Dev { 273 + scheme = "https" 274 + } 275 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 276 + xrpcc := &indigoxrpc.Client{ 277 + Host: host, 271 278 } 272 279 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 280 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 281 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 274 282 if err != nil { 283 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 284 + log.Println("failed to call XRPC repo.tags", xrpcerr) 285 + return nil, xrpcerr 286 + } 275 287 log.Println("failed to reach knotserver", err) 288 + return nil, err 289 + } 290 + 291 + var result types.RepoTagsResponse 292 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 293 + log.Println("failed to decode XRPC tags response", err) 276 294 return nil, err 277 295 } 278 296
+170
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/pagination" 13 + "tangled.sh/tangled.sh/core/appview/reporesolver" 14 + 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/gorilla/feeds" 17 + ) 18 + 19 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 20 + const feedLimitPerType = 100 21 + 22 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 32 + if err != nil { 33 + return nil, err 34 + } 35 + 36 + feed := &feeds.Feed{ 37 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 38 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 39 + Items: make([]*feeds.Item, 0), 40 + Updated: time.UnixMilli(0), 41 + } 42 + 43 + for _, pull := range pulls { 44 + items, err := rp.createPullItems(ctx, pull, f) 45 + if err != nil { 46 + return nil, err 47 + } 48 + feed.Items = append(feed.Items, items...) 49 + } 50 + 51 + for _, issue := range issues { 52 + item, err := rp.createIssueItem(ctx, issue, f) 53 + if err != nil { 54 + return nil, err 55 + } 56 + feed.Items = append(feed.Items, item) 57 + } 58 + 59 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 60 + if a.Created.After(b.Created) { 61 + return -1 62 + } 63 + return 1 64 + }) 65 + 66 + if len(feed.Items) > 0 { 67 + feed.Updated = feed.Items[0].Created 68 + } 69 + 70 + return feed, nil 71 + } 72 + 73 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 75 + if err != nil { 76 + return nil, err 77 + } 78 + 79 + var items []*feeds.Item 80 + 81 + state := rp.getPullState(pull) 82 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 83 + 84 + mainItem := &feeds.Item{ 85 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 86 + Description: description, 87 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 88 + Created: pull.Created, 89 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 90 + } 91 + items = append(items, mainItem) 92 + 93 + for _, round := range pull.Submissions { 94 + if round == nil || round.RoundNumber == 0 { 95 + continue 96 + } 97 + 98 + roundItem := &feeds.Item{ 99 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 100 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 101 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 102 + Created: round.Created, 103 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 104 + } 105 + items = append(items, roundItem) 106 + } 107 + 108 + return items, nil 109 + } 110 + 111 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 + if err != nil { 114 + return nil, err 115 + } 116 + 117 + state := "closed" 118 + if issue.Open { 119 + state = "opened" 120 + } 121 + 122 + return &feeds.Item{ 123 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 124 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 125 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 126 + Created: issue.Created, 127 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 128 + }, nil 129 + } 130 + 131 + func (rp *Repo) getPullState(pull *db.Pull) string { 132 + if pull.State == db.PullOpen { 133 + return "opened" 134 + } 135 + return pull.State.String() 136 + } 137 + 138 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 140 + 141 + if pull.State == db.PullMerged { 142 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 143 + } 144 + 145 + return fmt.Sprintf("%s in %s", base, repoName) 146 + } 147 + 148 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + f, err := rp.repoResolver.Resolve(r) 150 + if err != nil { 151 + log.Println("failed to fully resolve repo:", err) 152 + return 153 + } 154 + 155 + feed, err := rp.getRepoFeed(r.Context(), f) 156 + if err != nil { 157 + log.Println("failed to get repo feed:", err) 158 + rp.pages.Error500(w) 159 + return 160 + } 161 + 162 + atom, err := feed.ToAtom() 163 + if err != nil { 164 + rp.pages.Error500(w) 165 + return 166 + } 167 + 168 + w.Header().Set("content-type", "application/atom+xml") 169 + w.Write([]byte(atom)) 170 + }
+200 -102
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "encoding/json" 4 + "errors" 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 8 "slices" 9 9 "sort" 10 10 "strings" 11 + "sync" 12 + "time" 11 13 14 + "context" 15 + "encoding/json" 16 + 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 + "github.com/go-git/go-git/v5/plumbing" 19 + "tangled.sh/tangled.sh/core/api/tangled" 12 20 "tangled.sh/tangled.sh/core/appview/commitverify" 13 21 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 22 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/appview/pages/markup" 17 24 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 - "tangled.sh/tangled.sh/core/knotclient" 25 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 19 26 "tangled.sh/tangled.sh/core/types" 20 27 21 28 "github.com/go-chi/chi/v5" ··· 24 31 25 32 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 33 ref := chi.URLParam(r, "ref") 34 + 27 35 f, err := rp.repoResolver.Resolve(r) 28 36 if err != nil { 29 37 log.Println("failed to fully resolve repo", err) 30 38 return 31 39 } 32 40 33 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 34 - if err != nil { 35 - log.Printf("failed to create unsigned client for %s", f.Knot) 36 - rp.pages.Error503(w) 37 - return 41 + scheme := "http" 42 + if !rp.config.Core.Dev { 43 + scheme = "https" 44 + } 45 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 46 + xrpcc := &indigoxrpc.Client{ 47 + Host: host, 38 48 } 39 49 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 - if err != nil { 42 - rp.pages.Error503(w) 43 - log.Println("failed to reach knotserver", err) 44 - return 50 + user := rp.oauth.GetUser(r) 51 + repoInfo := f.RepoInfo(user) 52 + 53 + // Build index response from multiple XRPC calls 54 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 55 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 56 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 57 + log.Println("failed to call XRPC repo.index", err) 58 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 59 + LoggedInUser: user, 60 + NeedsKnotUpgrade: true, 61 + RepoInfo: repoInfo, 62 + }) 63 + return 64 + } else { 65 + rp.pages.Error503(w) 66 + log.Println("failed to build index response", err) 67 + return 68 + } 45 69 } 46 70 47 71 tagMap := make(map[string][]string) ··· 99 123 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 100 124 if err != nil { 101 125 log.Println(err) 102 - } 103 - 104 - user := rp.oauth.GetUser(r) 105 - repoInfo := f.RepoInfo(user) 106 - 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 126 } 127 127 128 128 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 129 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 130 130 if err != nil { 131 131 log.Printf("failed to compute language percentages: %s", err) 132 132 // non-fatal ··· 143 143 } 144 144 145 145 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, 146 + LoggedInUser: user, 147 + RepoInfo: repoInfo, 148 + TagMap: tagMap, 149 + RepoIndexResponse: *result, 150 + CommitsTrunc: commitsTrunc, 151 + TagsTrunc: tagsTrunc, 152 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 153 153 BranchesTrunc: branchesTrunc, 154 154 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 155 155 VerifiedCommits: vc, ··· 159 159 } 160 160 161 161 func (rp *Repo) getLanguageInfo( 162 + ctx context.Context, 162 163 f *reporesolver.ResolvedRepo, 163 - signedClient *knotclient.SignedClient, 164 + xrpcc *indigoxrpc.Client, 165 + currentRef string, 164 166 isDefaultRef bool, 165 167 ) ([]types.RepoLanguageDetails, error) { 166 168 // first attempt to fetch from db 167 169 langs, err := db.GetRepoLanguages( 168 170 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 171 + db.FilterEq("repo_at", f.RepoAt()), 172 + db.FilterEq("ref", currentRef), 171 173 ) 172 174 173 175 if err != nil || langs == nil { 174 - // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 176 + // non-fatal, fetch langs from ks via XRPC 177 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 178 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 176 179 if err != nil { 180 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 181 + log.Println("failed to call XRPC repo.languages", xrpcerr) 182 + return nil, xrpcerr 183 + } 177 184 return nil, err 178 185 } 179 - if ls == nil { 186 + 187 + if ls == nil || ls.Languages == nil { 180 188 return nil, nil 181 189 } 182 190 183 - for l, s := range ls.Languages { 191 + for _, lang := range ls.Languages { 184 192 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 193 + RepoAt: f.RepoAt(), 194 + Ref: currentRef, 187 195 IsDefaultRef: isDefaultRef, 188 - Language: l, 189 - Bytes: s, 196 + Language: lang.Name, 197 + Bytes: lang.Size, 190 198 }) 191 199 } 192 200 ··· 230 238 return languageStats, nil 231 239 } 232 240 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 - } 241 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 242 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 243 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 243 244 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) 245 + // first get branches to determine the ref if not specified 246 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 255 247 if err != nil { 256 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 257 248 return nil, err 258 249 } 259 250 260 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 261 - if err != nil { 262 - log.Println("failed to reach knotserver", err) 251 + var branchesResp types.RepoBranchesResponse 252 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 263 253 return nil, err 264 254 } 265 255 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 256 + // if no ref specified, use default branch or first available 257 + if ref == "" && len(branchesResp.Branches) > 0 { 258 + for _, branch := range branchesResp.Branches { 259 + if branch.IsDefault { 260 + ref = branch.Name 261 + break 262 + } 263 + } 264 + if ref == "" { 265 + ref = branchesResp.Branches[0].Name 266 + } 271 267 } 272 268 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 269 + // check if repo is empty 270 + if len(branchesResp.Branches) == 0 { 271 + return &types.RepoIndexResponse{ 272 + IsEmpty: true, 273 + Branches: branchesResp.Branches, 274 + }, nil 277 275 } 278 276 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 277 + // now run the remaining queries in parallel 278 + var wg sync.WaitGroup 279 + var errs error 280 + 281 + var ( 282 + tagsResp types.RepoTagsResponse 283 + treeResp *tangled.RepoTree_Output 284 + logResp types.RepoLogResponse 285 + readmeContent string 286 + readmeFileName string 287 + ) 288 + 289 + // tags 290 + wg.Add(1) 291 + go func() { 292 + defer wg.Done() 293 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 294 + if err != nil { 295 + errs = errors.Join(errs, err) 296 + return 297 + } 298 + 299 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 300 + errs = errors.Join(errs, err) 301 + } 302 + }() 303 + 304 + // tree/files 305 + wg.Add(1) 306 + go func() { 307 + defer wg.Done() 308 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 309 + if err != nil { 310 + errs = errors.Join(errs, err) 311 + return 312 + } 313 + treeResp = resp 314 + }() 315 + 316 + // commits 317 + wg.Add(1) 318 + go func() { 319 + defer wg.Done() 320 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 321 + if err != nil { 322 + errs = errors.Join(errs, err) 323 + return 324 + } 325 + 326 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 327 + errs = errors.Join(errs, err) 328 + } 329 + }() 330 + 331 + // readme content 332 + wg.Add(1) 333 + go func() { 334 + defer wg.Done() 335 + for _, filename := range markup.ReadmeFilenames { 336 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 + if err != nil { 338 + continue 339 + } 280 340 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 341 + if blobResp == nil { 342 + continue 343 + } 344 + 345 + readmeContent = blobResp.Content 346 + readmeFileName = filename 347 + break 348 + } 349 + }() 350 + 351 + wg.Wait() 352 + 353 + if errs != nil { 354 + return nil, errs 355 + } 356 + 357 + var files []types.NiceTree 358 + if treeResp != nil && treeResp.Files != nil { 359 + for _, file := range treeResp.Files { 360 + niceFile := types.NiceTree{ 361 + IsFile: file.Is_file, 362 + IsSubtree: file.Is_subtree, 363 + Name: file.Name, 364 + Mode: file.Mode, 365 + Size: file.Size, 366 + } 367 + if file.Last_commit != nil { 368 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 369 + niceFile.LastCommit = &types.LastCommitInfo{ 370 + Hash: plumbing.NewHash(file.Last_commit.Hash), 371 + Message: file.Last_commit.Message, 372 + When: when, 373 + } 374 + } 375 + files = append(files, niceFile) 376 + } 286 377 } 287 378 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 379 + result := &types.RepoIndexResponse{ 380 + IsEmpty: false, 381 + Ref: ref, 382 + Readme: readmeContent, 383 + ReadmeFileName: readmeFileName, 384 + Commits: logResp.Commits, 385 + Description: logResp.Description, 386 + Files: files, 387 + Branches: branchesResp.Branches, 388 + Tags: tagsResp.Tags, 389 + TotalCommits: logResp.Total, 291 390 } 292 391 293 - forkInfo.Status = status.Status 294 - return &forkInfo, nil 392 + return result, nil 295 393 }
+661 -361
appview/repo/repo.go
··· 11 11 "log/slog" 12 12 "net/http" 13 13 "net/url" 14 + "path" 14 15 "path/filepath" 15 16 "slices" 16 17 "strconv" 17 18 "strings" 18 19 "time" 19 20 21 + comatproto "github.com/bluesky-social/indigo/api/atproto" 22 + lexutil "github.com/bluesky-social/indigo/lex/util" 23 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 24 "tangled.sh/tangled.sh/core/api/tangled" 21 25 "tangled.sh/tangled.sh/core/appview/commitverify" 22 26 "tangled.sh/tangled.sh/core/appview/config" ··· 26 30 "tangled.sh/tangled.sh/core/appview/pages" 27 31 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 32 "tangled.sh/tangled.sh/core/appview/reporesolver" 33 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 34 "tangled.sh/tangled.sh/core/eventconsumer" 30 35 "tangled.sh/tangled.sh/core/idresolver" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 36 "tangled.sh/tangled.sh/core/patchutil" 33 37 "tangled.sh/tangled.sh/core/rbac" 34 38 "tangled.sh/tangled.sh/core/tid" 35 39 "tangled.sh/tangled.sh/core/types" 40 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 36 41 37 42 securejoin "github.com/cyphar/filepath-securejoin" 38 43 "github.com/go-chi/chi/v5" 39 44 "github.com/go-git/go-git/v5/plumbing" 40 45 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 46 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 47 ) 45 48 46 49 type Repo struct { ··· 54 57 enforcer *rbac.Enforcer 55 58 notifier notify.Notifier 56 59 logger *slog.Logger 60 + serviceAuth *serviceauth.ServiceAuth 57 61 } 58 62 59 63 func New( ··· 81 85 } 82 86 } 83 87 88 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 89 + refParam := chi.URLParam(r, "ref") 90 + f, err := rp.repoResolver.Resolve(r) 91 + if err != nil { 92 + log.Println("failed to get repo and knot", err) 93 + return 94 + } 95 + 96 + scheme := "http" 97 + if !rp.config.Core.Dev { 98 + scheme = "https" 99 + } 100 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 101 + xrpcc := &indigoxrpc.Client{ 102 + Host: host, 103 + } 104 + 105 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 106 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo) 107 + if err != nil { 108 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 109 + log.Println("failed to call XRPC repo.archive", xrpcerr) 110 + rp.pages.Error503(w) 111 + return 112 + } 113 + rp.pages.Error404(w) 114 + return 115 + } 116 + 117 + // Set headers for file download 118 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam) 119 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 120 + w.Header().Set("Content-Type", "application/gzip") 121 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 122 + 123 + // Write the archive data directly 124 + w.Write(archiveBytes) 125 + } 126 + 84 127 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 85 128 f, err := rp.repoResolver.Resolve(r) 86 129 if err != nil { ··· 98 141 99 142 ref := chi.URLParam(r, "ref") 100 143 101 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 144 + scheme := "http" 145 + if !rp.config.Core.Dev { 146 + scheme = "https" 147 + } 148 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 149 + xrpcc := &indigoxrpc.Client{ 150 + Host: host, 151 + } 152 + 153 + limit := int64(60) 154 + cursor := "" 155 + if page > 1 { 156 + // Convert page number to cursor (offset) 157 + offset := (page - 1) * int(limit) 158 + cursor = strconv.Itoa(offset) 159 + } 160 + 161 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 162 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 102 163 if err != nil { 103 - log.Println("failed to create unsigned client", err) 164 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 165 + log.Println("failed to call XRPC repo.log", xrpcerr) 166 + rp.pages.Error503(w) 167 + return 168 + } 169 + rp.pages.Error404(w) 104 170 return 105 171 } 106 172 107 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 108 - if err != nil { 109 - log.Println("failed to reach knotserver", err) 173 + var xrpcResp types.RepoLogResponse 174 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 + log.Println("failed to decode XRPC response", err) 176 + rp.pages.Error503(w) 110 177 return 111 178 } 112 179 113 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 180 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 114 181 if err != nil { 115 - log.Println("failed to reach knotserver", err) 116 - return 182 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 + log.Println("failed to call XRPC repo.tags", xrpcerr) 184 + rp.pages.Error503(w) 185 + return 186 + } 117 187 } 118 188 119 189 tagMap := make(map[string][]string) 120 - for _, tag := range tagResult.Tags { 121 - hash := tag.Hash 122 - if tag.Tag != nil { 123 - hash = tag.Tag.Target.String() 190 + if tagBytes != nil { 191 + var tagResp types.RepoTagsResponse 192 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 193 + for _, tag := range tagResp.Tags { 194 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 195 + } 124 196 } 125 - tagMap[hash] = append(tagMap[hash], tag.Name) 126 197 } 127 198 128 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 199 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 129 200 if err != nil { 130 - log.Println("failed to reach knotserver", err) 131 - return 201 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 202 + log.Println("failed to call XRPC repo.branches", xrpcerr) 203 + rp.pages.Error503(w) 204 + return 205 + } 132 206 } 133 207 134 - for _, branch := range branchResult.Branches { 135 - hash := branch.Hash 136 - tagMap[hash] = append(tagMap[hash], branch.Name) 208 + if branchBytes != nil { 209 + var branchResp types.RepoBranchesResponse 210 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 211 + for _, branch := range branchResp.Branches { 212 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 213 + } 214 + } 137 215 } 138 216 139 217 user := rp.oauth.GetUser(r) 140 218 141 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 219 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 142 220 if err != nil { 143 221 log.Println("failed to fetch email to did mapping", err) 144 222 } 145 223 146 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 224 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 147 225 if err != nil { 148 226 log.Println(err) 149 227 } ··· 151 229 repoInfo := f.RepoInfo(user) 152 230 153 231 var shas []string 154 - for _, c := range repolog.Commits { 232 + for _, c := range xrpcResp.Commits { 155 233 shas = append(shas, c.Hash.String()) 156 234 } 157 235 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 164 242 LoggedInUser: user, 165 243 TagMap: tagMap, 166 244 RepoInfo: repoInfo, 167 - RepoLogResponse: *repolog, 245 + RepoLogResponse: xrpcResp, 168 246 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 169 247 VerifiedCommits: vc, 170 248 Pipelines: pipelines, ··· 193 271 return 194 272 } 195 273 196 - repoAt := f.RepoAt 274 + repoAt := f.RepoAt() 197 275 rkey := repoAt.RecordKey().String() 198 276 if rkey == "" { 199 277 log.Println("invalid aturi for repo", err) ··· 243 321 Record: &lexutil.LexiconTypeDecoder{ 244 322 Val: &tangled.Repo{ 245 323 Knot: f.Knot, 246 - Name: f.RepoName, 324 + Name: f.Name, 247 325 Owner: user.Did, 248 - CreatedAt: f.CreatedAt, 326 + CreatedAt: f.Created.Format(time.RFC3339), 249 327 Description: &newDescription, 250 328 Spindle: &f.Spindle, 251 329 }, ··· 276 354 return 277 355 } 278 356 ref := chi.URLParam(r, "ref") 279 - protocol := "http" 280 - if !rp.config.Core.Dev { 281 - protocol = "https" 282 - } 283 357 284 358 var diffOpts types.DiffOpts 285 359 if d := r.URL.Query().Get("diff"); d == "split" { ··· 291 365 return 292 366 } 293 367 294 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 295 - if err != nil { 296 - log.Println("failed to reach knotserver", err) 297 - return 368 + scheme := "http" 369 + if !rp.config.Core.Dev { 370 + scheme = "https" 371 + } 372 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 373 + xrpcc := &indigoxrpc.Client{ 374 + Host: host, 298 375 } 299 376 300 - body, err := io.ReadAll(resp.Body) 377 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 378 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 301 379 if err != nil { 302 - log.Printf("Error reading response body: %v", err) 380 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 381 + log.Println("failed to call XRPC repo.diff", xrpcerr) 382 + rp.pages.Error503(w) 383 + return 384 + } 385 + rp.pages.Error404(w) 303 386 return 304 387 } 305 388 306 389 var result types.RepoCommitResponse 307 - err = json.Unmarshal(body, &result) 308 - if err != nil { 309 - log.Println("failed to parse response:", err) 390 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 391 + log.Println("failed to decode XRPC response", err) 392 + rp.pages.Error503(w) 310 393 return 311 394 } 312 395 ··· 352 435 353 436 ref := chi.URLParam(r, "ref") 354 437 treePath := chi.URLParam(r, "*") 355 - protocol := "http" 438 + 439 + // if the tree path has a trailing slash, let's strip it 440 + // so we don't 404 441 + treePath = strings.TrimSuffix(treePath, "/") 442 + 443 + scheme := "http" 356 444 if !rp.config.Core.Dev { 357 - protocol = "https" 445 + scheme = "https" 358 446 } 359 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 360 - if err != nil { 361 - log.Println("failed to reach knotserver", err) 362 - return 447 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 448 + xrpcc := &indigoxrpc.Client{ 449 + Host: host, 363 450 } 364 451 365 - body, err := io.ReadAll(resp.Body) 452 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 453 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 366 454 if err != nil { 367 - log.Printf("Error reading response body: %v", err) 455 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 456 + log.Println("failed to call XRPC repo.tree", xrpcerr) 457 + rp.pages.Error503(w) 458 + return 459 + } 460 + rp.pages.Error404(w) 368 461 return 369 462 } 370 463 371 - var result types.RepoTreeResponse 372 - err = json.Unmarshal(body, &result) 373 - if err != nil { 374 - log.Println("failed to parse response:", err) 375 - return 464 + // Convert XRPC response to internal types.RepoTreeResponse 465 + files := make([]types.NiceTree, len(xrpcResp.Files)) 466 + for i, xrpcFile := range xrpcResp.Files { 467 + file := types.NiceTree{ 468 + Name: xrpcFile.Name, 469 + Mode: xrpcFile.Mode, 470 + Size: int64(xrpcFile.Size), 471 + IsFile: xrpcFile.Is_file, 472 + IsSubtree: xrpcFile.Is_subtree, 473 + } 474 + 475 + // Convert last commit info if present 476 + if xrpcFile.Last_commit != nil { 477 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 478 + file.LastCommit = &types.LastCommitInfo{ 479 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 480 + Message: xrpcFile.Last_commit.Message, 481 + When: commitWhen, 482 + } 483 + } 484 + 485 + files[i] = file 486 + } 487 + 488 + result := types.RepoTreeResponse{ 489 + Ref: xrpcResp.Ref, 490 + Files: files, 491 + } 492 + 493 + if xrpcResp.Parent != nil { 494 + result.Parent = *xrpcResp.Parent 495 + } 496 + if xrpcResp.Dotdot != nil { 497 + result.DotDot = *xrpcResp.Dotdot 376 498 } 377 499 378 500 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, ··· 386 508 user := rp.oauth.GetUser(r) 387 509 388 510 var breadcrumbs [][]string 389 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 511 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 390 512 if treePath != "" { 391 513 for idx, elem := range strings.Split(treePath, "/") { 392 514 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 411 533 return 412 534 } 413 535 414 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 536 + scheme := "http" 537 + if !rp.config.Core.Dev { 538 + scheme = "https" 539 + } 540 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 541 + xrpcc := &indigoxrpc.Client{ 542 + Host: host, 543 + } 544 + 545 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 546 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 415 547 if err != nil { 416 - log.Println("failed to create unsigned client", err) 548 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 549 + log.Println("failed to call XRPC repo.tags", xrpcerr) 550 + rp.pages.Error503(w) 551 + return 552 + } 553 + rp.pages.Error404(w) 417 554 return 418 555 } 419 556 420 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 421 - if err != nil { 422 - log.Println("failed to reach knotserver", err) 557 + var result types.RepoTagsResponse 558 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 559 + log.Println("failed to decode XRPC response", err) 560 + rp.pages.Error503(w) 423 561 return 424 562 } 425 563 426 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 564 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 427 565 if err != nil { 428 566 log.Println("failed grab artifacts", err) 429 567 return ··· 455 593 rp.pages.RepoTags(w, pages.RepoTagsParams{ 456 594 LoggedInUser: user, 457 595 RepoInfo: f.RepoInfo(user), 458 - RepoTagsResponse: *result, 596 + RepoTagsResponse: result, 459 597 ArtifactMap: artifactMap, 460 598 DanglingArtifacts: danglingArtifacts, 461 599 }) ··· 468 606 return 469 607 } 470 608 471 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 609 + scheme := "http" 610 + if !rp.config.Core.Dev { 611 + scheme = "https" 612 + } 613 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 614 + xrpcc := &indigoxrpc.Client{ 615 + Host: host, 616 + } 617 + 618 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 619 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 472 620 if err != nil { 473 - log.Println("failed to create unsigned client", err) 621 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 622 + log.Println("failed to call XRPC repo.branches", xrpcerr) 623 + rp.pages.Error503(w) 624 + return 625 + } 626 + rp.pages.Error404(w) 474 627 return 475 628 } 476 629 477 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 478 - if err != nil { 479 - log.Println("failed to reach knotserver", err) 630 + var result types.RepoBranchesResponse 631 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 632 + log.Println("failed to decode XRPC response", err) 633 + rp.pages.Error503(w) 480 634 return 481 635 } 482 636 ··· 486 640 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 487 641 LoggedInUser: user, 488 642 RepoInfo: f.RepoInfo(user), 489 - RepoBranchesResponse: *result, 643 + RepoBranchesResponse: result, 490 644 }) 491 645 } 492 646 ··· 499 653 500 654 ref := chi.URLParam(r, "ref") 501 655 filePath := chi.URLParam(r, "*") 502 - protocol := "http" 656 + 657 + scheme := "http" 503 658 if !rp.config.Core.Dev { 504 - protocol = "https" 659 + scheme = "https" 505 660 } 506 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 507 - if err != nil { 508 - log.Println("failed to reach knotserver", err) 509 - return 661 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 662 + xrpcc := &indigoxrpc.Client{ 663 + Host: host, 510 664 } 511 665 512 - body, err := io.ReadAll(resp.Body) 666 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 667 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 513 668 if err != nil { 514 - log.Printf("Error reading response body: %v", err) 669 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 670 + log.Println("failed to call XRPC repo.blob", xrpcerr) 671 + rp.pages.Error503(w) 672 + return 673 + } 674 + rp.pages.Error404(w) 515 675 return 516 676 } 517 677 518 - var result types.RepoBlobResponse 519 - err = json.Unmarshal(body, &result) 520 - if err != nil { 521 - log.Println("failed to parse response:", err) 522 - return 523 - } 678 + // Use XRPC response directly instead of converting to internal types 524 679 525 680 var breadcrumbs [][]string 526 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 681 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 527 682 if filePath != "" { 528 683 for idx, elem := range strings.Split(filePath, "/") { 529 684 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 533 688 showRendered := false 534 689 renderToggle := false 535 690 536 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 691 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 537 692 renderToggle = true 538 693 showRendered = r.URL.Query().Get("code") != "true" 539 694 } ··· 543 698 var isVideo bool 544 699 var contentSrc string 545 700 546 - if result.IsBinary { 547 - ext := strings.ToLower(filepath.Ext(result.Path)) 701 + if resp.IsBinary != nil && *resp.IsBinary { 702 + ext := strings.ToLower(filepath.Ext(resp.Path)) 548 703 switch ext { 549 704 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 550 705 isImage = true ··· 554 709 unsupported = true 555 710 } 556 711 557 - // fetch the actual binary content like in RepoBlobRaw 712 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 713 + repoName := path.Join("%s/%s", f.OwnerDid(), f.Name) 714 + blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 715 + scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath)) 558 716 559 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 560 717 contentSrc = blobURL 561 718 if !rp.config.Core.Dev { 562 719 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 563 720 } 564 721 } 565 722 723 + lines := 0 724 + if resp.IsBinary == nil || !*resp.IsBinary { 725 + lines = strings.Count(resp.Content, "\n") + 1 726 + } 727 + 728 + var sizeHint uint64 729 + if resp.Size != nil { 730 + sizeHint = uint64(*resp.Size) 731 + } else { 732 + sizeHint = uint64(len(resp.Content)) 733 + } 734 + 566 735 user := rp.oauth.GetUser(r) 736 + 737 + // Determine if content is binary (dereference pointer) 738 + isBinary := false 739 + if resp.IsBinary != nil { 740 + isBinary = *resp.IsBinary 741 + } 742 + 567 743 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 568 - LoggedInUser: user, 569 - RepoInfo: f.RepoInfo(user), 570 - RepoBlobResponse: result, 571 - BreadCrumbs: breadcrumbs, 572 - ShowRendered: showRendered, 573 - RenderToggle: renderToggle, 574 - Unsupported: unsupported, 575 - IsImage: isImage, 576 - IsVideo: isVideo, 577 - ContentSrc: contentSrc, 744 + LoggedInUser: user, 745 + RepoInfo: f.RepoInfo(user), 746 + BreadCrumbs: breadcrumbs, 747 + ShowRendered: showRendered, 748 + RenderToggle: renderToggle, 749 + Unsupported: unsupported, 750 + IsImage: isImage, 751 + IsVideo: isVideo, 752 + ContentSrc: contentSrc, 753 + RepoBlob_Output: resp, 754 + Contents: resp.Content, 755 + Lines: lines, 756 + SizeHint: sizeHint, 757 + IsBinary: isBinary, 578 758 }) 579 759 } 580 760 ··· 589 769 ref := chi.URLParam(r, "ref") 590 770 filePath := chi.URLParam(r, "*") 591 771 592 - protocol := "http" 772 + scheme := "http" 593 773 if !rp.config.Core.Dev { 594 - protocol = "https" 774 + scheme = "https" 775 + } 776 + 777 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 778 + blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 779 + scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath)) 780 + 781 + req, err := http.NewRequest("GET", blobURL, nil) 782 + if err != nil { 783 + log.Println("failed to create request", err) 784 + return 595 785 } 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) 786 + 787 + // forward the If-None-Match header 788 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 789 + req.Header.Set("If-None-Match", clientETag) 790 + } 791 + 792 + client := &http.Client{} 793 + resp, err := client.Do(req) 598 794 if err != nil { 599 - log.Println("failed to reach knotserver:", err) 795 + log.Println("failed to reach knotserver", err) 600 796 rp.pages.Error503(w) 601 797 return 602 798 } 603 799 defer resp.Body.Close() 604 800 801 + // forward 304 not modified 802 + if resp.StatusCode == http.StatusNotModified { 803 + w.WriteHeader(http.StatusNotModified) 804 + return 805 + } 806 + 605 807 if resp.StatusCode != http.StatusOK { 606 808 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 607 809 w.WriteHeader(resp.StatusCode) ··· 617 819 return 618 820 } 619 821 620 - if strings.Contains(contentType, "text/plain") { 822 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 823 + // serve all textual content as text/plain 621 824 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 622 825 w.Write(body) 623 826 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 827 + // serve images and videos with their original content type 624 828 w.Header().Set("Content-Type", contentType) 625 829 w.Write(body) 626 830 } else { ··· 630 834 } 631 835 } 632 836 837 + // isTextualMimeType returns true if the MIME type represents textual content 838 + // that should be served as text/plain 839 + func isTextualMimeType(mimeType string) bool { 840 + textualTypes := []string{ 841 + "application/json", 842 + "application/xml", 843 + "application/yaml", 844 + "application/x-yaml", 845 + "application/toml", 846 + "application/javascript", 847 + "application/ecmascript", 848 + "message/", 849 + } 850 + 851 + return slices.Contains(textualTypes, mimeType) 852 + } 853 + 633 854 // modify the spindle configured for this repo 634 855 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 635 856 user := rp.oauth.GetUser(r) ··· 649 870 return 650 871 } 651 872 652 - repoAt := f.RepoAt 873 + repoAt := f.RepoAt() 653 874 rkey := repoAt.RecordKey().String() 654 875 if rkey == "" { 655 876 fail("Failed to resolve repo. Try again later", err) ··· 657 878 } 658 879 659 880 newSpindle := r.FormValue("spindle") 881 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 660 882 client, err := rp.oauth.AuthorizedClient(r) 661 883 if err != nil { 662 884 fail("Failed to authorize. Try again later.", err) 663 885 return 664 886 } 665 887 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 888 + if !removingSpindle { 889 + // ensure that this is a valid spindle for this user 890 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 891 + if err != nil { 892 + fail("Failed to find spindles. Try again later.", err) 893 + return 894 + } 895 + 896 + if !slices.Contains(validSpindles, newSpindle) { 897 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 898 + return 899 + } 671 900 } 672 901 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 902 + spindlePtr := &newSpindle 903 + if removingSpindle { 904 + spindlePtr = nil 676 905 } 677 906 678 907 // optimistic update 679 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 908 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 680 909 if err != nil { 681 910 fail("Failed to update spindle. Try again later.", err) 682 911 return ··· 695 924 Record: &lexutil.LexiconTypeDecoder{ 696 925 Val: &tangled.Repo{ 697 926 Knot: f.Knot, 698 - Name: f.RepoName, 927 + Name: f.Name, 699 928 Owner: user.Did, 700 - CreatedAt: f.CreatedAt, 929 + CreatedAt: f.Created.Format(time.RFC3339), 701 930 Description: &f.Description, 702 - Spindle: &newSpindle, 931 + Spindle: spindlePtr, 703 932 }, 704 933 }, 705 934 }) ··· 709 938 return 710 939 } 711 940 712 - // add this spindle to spindle stream 713 - rp.spindlestream.AddSource( 714 - context.Background(), 715 - eventconsumer.NewSpindleSource(newSpindle), 716 - ) 941 + if !removingSpindle { 942 + // add this spindle to spindle stream 943 + rp.spindlestream.AddSource( 944 + context.Background(), 945 + eventconsumer.NewSpindleSource(newSpindle), 946 + ) 947 + } 717 948 718 949 rp.pages.HxRefresh(w) 719 950 } ··· 776 1007 Record: &lexutil.LexiconTypeDecoder{ 777 1008 Val: &tangled.RepoCollaborator{ 778 1009 Subject: collaboratorIdent.DID.String(), 779 - Repo: string(f.RepoAt), 1010 + Repo: string(f.RepoAt()), 780 1011 CreatedAt: createdAt.Format(time.RFC3339), 781 1012 }}, 782 1013 }) ··· 785 1016 fail("Failed to write record to PDS.", err) 786 1017 return 787 1018 } 788 - l = l.With("at-uri", resp.Uri) 1019 + 1020 + aturi := resp.Uri 1021 + l = l.With("at-uri", aturi) 789 1022 l.Info("wrote record to PDS") 790 1023 791 - l.Info("adding to knot") 792 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1024 + tx, err := rp.db.BeginTx(r.Context(), nil) 793 1025 if err != nil { 794 - fail("Failed to add to knot.", err) 1026 + fail("Failed to add collaborator.", err) 795 1027 return 796 1028 } 797 1029 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 - } 1030 + rollback := func() { 1031 + err1 := tx.Rollback() 1032 + err2 := rp.enforcer.E.LoadPolicy() 1033 + err3 := rollbackRecord(context.Background(), aturi, client) 803 1034 804 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 805 - if err != nil { 806 - fail("Knot was unreachable.", err) 807 - return 808 - } 809 - 810 - if ksResp.StatusCode != http.StatusNoContent { 811 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 812 - return 813 - } 1035 + // ignore txn complete errors, this is okay 1036 + if errors.Is(err1, sql.ErrTxDone) { 1037 + err1 = nil 1038 + } 814 1039 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) 1040 + if errs := errors.Join(err1, err2, err3); errs != nil { 1041 + l.Error("failed to rollback changes", "errs", errs) 1042 + return 825 1043 } 826 - }() 1044 + } 1045 + defer rollback() 827 1046 828 1047 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 829 1048 if err != nil { ··· 835 1054 Did: syntax.DID(currentUser.Did), 836 1055 Rkey: rkey, 837 1056 SubjectDid: collaboratorIdent.DID, 838 - RepoAt: f.RepoAt, 1057 + RepoAt: f.RepoAt(), 839 1058 Created: createdAt, 840 1059 }) 841 1060 if err != nil { ··· 855 1074 return 856 1075 } 857 1076 1077 + // clear aturi to when everything is successful 1078 + aturi = "" 1079 + 858 1080 rp.pages.HxRefresh(w) 859 1081 } 860 1082 861 1083 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 862 1084 user := rp.oauth.GetUser(r) 863 1085 1086 + noticeId := "operation-error" 864 1087 f, err := rp.repoResolver.Resolve(r) 865 1088 if err != nil { 866 1089 log.Println("failed to get repo and knot", err) ··· 873 1096 log.Println("failed to get authorized client", err) 874 1097 return 875 1098 } 876 - repoRkey := f.RepoAt.RecordKey().String() 877 1099 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 878 1100 Collection: tangled.RepoNSID, 879 1101 Repo: user.Did, 880 - Rkey: repoRkey, 1102 + Rkey: f.Rkey, 881 1103 }) 882 1104 if err != nil { 883 1105 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) 1106 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 892 1107 return 893 1108 } 1109 + log.Println("removed repo record ", f.RepoAt().String()) 894 1110 895 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1111 + client, err := rp.oauth.ServiceClient( 1112 + r, 1113 + oauth.WithService(f.Knot), 1114 + oauth.WithLxm(tangled.RepoDeleteNSID), 1115 + oauth.WithDev(rp.config.Core.Dev), 1116 + ) 896 1117 if err != nil { 897 - log.Println("failed to create client to ", f.Knot) 1118 + log.Println("failed to connect to knot server:", err) 898 1119 return 899 1120 } 900 1121 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) 1122 + err = tangled.RepoDelete( 1123 + r.Context(), 1124 + client, 1125 + &tangled.RepoDelete_Input{ 1126 + Did: f.OwnerDid(), 1127 + Name: f.Name, 1128 + Rkey: f.Rkey, 1129 + }, 1130 + ) 1131 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1132 + rp.pages.Notice(w, noticeId, err.Error()) 904 1133 return 905 1134 } 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 - } 1135 + log.Println("deleted repo from knot") 912 1136 913 1137 tx, err := rp.db.BeginTx(r.Context(), nil) 914 1138 if err != nil { ··· 927 1151 // remove collaborator RBAC 928 1152 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 929 1153 if err != nil { 930 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1154 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 931 1155 return 932 1156 } 933 1157 for _, c := range repoCollaborators { ··· 939 1163 // remove repo RBAC 940 1164 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 941 1165 if err != nil { 942 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1166 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 943 1167 return 944 1168 } 945 1169 946 1170 // remove repo from db 947 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 1171 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 948 1172 if err != nil { 949 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1173 + rp.pages.Notice(w, noticeId, "Failed to update appview") 950 1174 return 951 1175 } 952 1176 log.Println("removed repo from db") ··· 975 1199 return 976 1200 } 977 1201 1202 + noticeId := "operation-error" 978 1203 branch := r.FormValue("branch") 979 1204 if branch == "" { 980 1205 http.Error(w, "malformed form", http.StatusBadRequest) 981 1206 return 982 1207 } 983 1208 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) 991 - if err != nil { 992 - log.Println("failed to create client to ", f.Knot) 993 - return 994 - } 995 - 996 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1209 + client, err := rp.oauth.ServiceClient( 1210 + r, 1211 + oauth.WithService(f.Knot), 1212 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1213 + oauth.WithDev(rp.config.Core.Dev), 1214 + ) 997 1215 if err != nil { 998 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1216 + log.Println("failed to connect to knot server:", err) 1217 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 999 1218 return 1000 1219 } 1001 1220 1002 - if ksResp.StatusCode != http.StatusNoContent { 1003 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1221 + xe := tangled.RepoSetDefaultBranch( 1222 + r.Context(), 1223 + client, 1224 + &tangled.RepoSetDefaultBranch_Input{ 1225 + Repo: f.RepoAt().String(), 1226 + DefaultBranch: branch, 1227 + }, 1228 + ) 1229 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1230 + log.Println("xrpc failed", "err", xe) 1231 + rp.pages.Notice(w, noticeId, err.Error()) 1004 1232 return 1005 1233 } 1006 1234 1007 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1235 + rp.pages.HxRefresh(w) 1008 1236 } 1009 1237 1010 1238 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1033 1261 r, 1034 1262 oauth.WithService(f.Spindle), 1035 1263 oauth.WithLxm(lxm), 1264 + oauth.WithExp(60), 1036 1265 oauth.WithDev(rp.config.Core.Dev), 1037 1266 ) 1038 1267 if err != nil { ··· 1060 1289 r.Context(), 1061 1290 spindleClient, 1062 1291 &tangled.RepoAddSecret_Input{ 1063 - Repo: f.RepoAt.String(), 1292 + Repo: f.RepoAt().String(), 1064 1293 Key: key, 1065 1294 Value: value, 1066 1295 }, ··· 1078 1307 r.Context(), 1079 1308 spindleClient, 1080 1309 &tangled.RepoRemoveSecret_Input{ 1081 - Repo: f.RepoAt.String(), 1310 + Repo: f.RepoAt().String(), 1082 1311 Key: key, 1083 1312 }, 1084 1313 ) ··· 1119 1348 case "pipelines": 1120 1349 rp.pipelineSettings(w, r) 1121 1350 } 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 1351 } 1183 1352 1184 1353 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1185 1354 f, err := rp.repoResolver.Resolve(r) 1186 1355 user := rp.oauth.GetUser(r) 1187 1356 1188 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1357 + scheme := "http" 1358 + if !rp.config.Core.Dev { 1359 + scheme = "https" 1360 + } 1361 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1362 + xrpcc := &indigoxrpc.Client{ 1363 + Host: host, 1364 + } 1365 + 1366 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1367 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1189 1368 if err != nil { 1190 - log.Println("failed to create unsigned client", err) 1369 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1370 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1371 + rp.pages.Error503(w) 1372 + return 1373 + } 1374 + rp.pages.Error503(w) 1191 1375 return 1192 1376 } 1193 1377 1194 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1195 - if err != nil { 1196 - log.Println("failed to reach knotserver", err) 1378 + var result types.RepoBranchesResponse 1379 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1380 + log.Println("failed to decode XRPC response", err) 1381 + rp.pages.Error503(w) 1197 1382 return 1198 1383 } 1199 1384 ··· 1241 1426 r, 1242 1427 oauth.WithService(f.Spindle), 1243 1428 oauth.WithLxm(tangled.RepoListSecretsNSID), 1429 + oauth.WithExp(60), 1244 1430 oauth.WithDev(rp.config.Core.Dev), 1245 1431 ); err != nil { 1246 1432 log.Println("failed to create spindle client", err) 1247 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1433 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1248 1434 log.Println("failed to fetch secrets", err) 1249 1435 } else { 1250 1436 secrets = resp.Secrets ··· 1285 1471 } 1286 1472 1287 1473 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1474 + ref := chi.URLParam(r, "ref") 1475 + 1288 1476 user := rp.oauth.GetUser(r) 1289 1477 f, err := rp.repoResolver.Resolve(r) 1290 1478 if err != nil { ··· 1294 1482 1295 1483 switch r.Method { 1296 1484 case http.MethodPost: 1297 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1485 + client, err := rp.oauth.ServiceClient( 1486 + r, 1487 + oauth.WithService(f.Knot), 1488 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1489 + oauth.WithDev(rp.config.Core.Dev), 1490 + ) 1298 1491 if err != nil { 1299 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1492 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1300 1493 return 1301 1494 } 1302 1495 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.") 1496 + repoInfo := f.RepoInfo(user) 1497 + if repoInfo.Source == nil { 1498 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1306 1499 return 1307 1500 } 1308 1501 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.") 1502 + err = tangled.RepoForkSync( 1503 + r.Context(), 1504 + client, 1505 + &tangled.RepoForkSync_Input{ 1506 + Did: user.Did, 1507 + Name: f.Name, 1508 + Source: repoInfo.Source.RepoAt().String(), 1509 + Branch: ref, 1510 + }, 1511 + ) 1512 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1513 + rp.pages.Notice(w, "repo", err.Error()) 1321 1514 return 1322 1515 } 1323 1516 ··· 1350 1543 }) 1351 1544 1352 1545 case http.MethodPost: 1546 + l := rp.logger.With("handler", "ForkRepo") 1353 1547 1354 - knot := r.FormValue("knot") 1355 - if knot == "" { 1548 + targetKnot := r.FormValue("knot") 1549 + if targetKnot == "" { 1356 1550 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1357 1551 return 1358 1552 } 1553 + l = l.With("targetKnot", targetKnot) 1359 1554 1360 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1555 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1361 1556 if err != nil || !ok { 1362 1557 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1363 1558 return 1364 1559 } 1365 1560 1366 - forkName := fmt.Sprintf("%s", f.RepoName) 1367 - 1561 + // choose a name for a fork 1562 + forkName := f.Name 1368 1563 // this check is *only* to see if the forked repo name already exists 1369 1564 // in the user's account. 1370 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1565 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1371 1566 if err != nil { 1372 1567 if errors.Is(err, sql.ErrNoRows) { 1373 1568 // no existing repo with this name found, we can use the name as is ··· 1380 1575 // repo with this name already exists, append random string 1381 1576 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1382 1577 } 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 - } 1388 - 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 - } 1578 + l = l.With("forkName", forkName) 1394 1579 1395 - var uri string 1580 + uri := "https" 1396 1581 if rp.config.Core.Dev { 1397 1582 uri = "http" 1398 - } else { 1399 - uri = "https" 1400 1583 } 1401 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1402 - sourceAt := f.RepoAt.String() 1403 1584 1585 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1586 + l = l.With("cloneUrl", forkSourceUrl) 1587 + 1588 + sourceAt := f.RepoAt().String() 1589 + 1590 + // create an atproto record for this fork 1404 1591 rkey := tid.TID() 1405 1592 repo := &db.Repo{ 1406 1593 Did: user.Did, 1407 1594 Name: forkName, 1408 - Knot: knot, 1595 + Knot: targetKnot, 1409 1596 Rkey: rkey, 1410 1597 Source: sourceAt, 1411 1598 } 1412 1599 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 1600 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1444 1601 if err != nil { 1445 - log.Println("failed to get authorized client", err) 1446 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1602 + l.Error("failed to create xrpcclient", "err", err) 1603 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1447 1604 return 1448 1605 } 1449 1606 ··· 1462 1619 }}, 1463 1620 }) 1464 1621 if err != nil { 1465 - log.Printf("failed to create record: %s", err) 1622 + l.Error("failed to write to PDS", "err", err) 1466 1623 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1467 1624 return 1468 1625 } 1469 - log.Println("created repo record: ", atresp.Uri) 1470 1626 1471 - repo.AtUri = atresp.Uri 1627 + aturi := atresp.Uri 1628 + l = l.With("aturi", aturi) 1629 + l.Info("wrote to PDS") 1630 + 1631 + tx, err := rp.db.BeginTx(r.Context(), nil) 1632 + if err != nil { 1633 + l.Info("txn failed", "err", err) 1634 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1635 + return 1636 + } 1637 + 1638 + // The rollback function reverts a few things on failure: 1639 + // - the pending txn 1640 + // - the ACLs 1641 + // - the atproto record created 1642 + rollback := func() { 1643 + err1 := tx.Rollback() 1644 + err2 := rp.enforcer.E.LoadPolicy() 1645 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1646 + 1647 + // ignore txn complete errors, this is okay 1648 + if errors.Is(err1, sql.ErrTxDone) { 1649 + err1 = nil 1650 + } 1651 + 1652 + if errs := errors.Join(err1, err2, err3); errs != nil { 1653 + l.Error("failed to rollback changes", "errs", errs) 1654 + return 1655 + } 1656 + } 1657 + defer rollback() 1658 + 1659 + client, err := rp.oauth.ServiceClient( 1660 + r, 1661 + oauth.WithService(targetKnot), 1662 + oauth.WithLxm(tangled.RepoCreateNSID), 1663 + oauth.WithDev(rp.config.Core.Dev), 1664 + ) 1665 + if err != nil { 1666 + l.Error("could not create service client", "err", err) 1667 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1668 + return 1669 + } 1670 + 1671 + err = tangled.RepoCreate( 1672 + r.Context(), 1673 + client, 1674 + &tangled.RepoCreate_Input{ 1675 + Rkey: rkey, 1676 + Source: &forkSourceUrl, 1677 + }, 1678 + ) 1679 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1680 + rp.pages.Notice(w, "repo", err.Error()) 1681 + return 1682 + } 1683 + 1472 1684 err = db.AddRepo(tx, repo) 1473 1685 if err != nil { 1474 1686 log.Println(err) ··· 1478 1690 1479 1691 // acls 1480 1692 p, _ := securejoin.SecureJoin(user.Did, forkName) 1481 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1693 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1482 1694 if err != nil { 1483 1695 log.Println(err) 1484 1696 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1499 1711 return 1500 1712 } 1501 1713 1714 + // reset the ATURI because the transaction completed successfully 1715 + aturi = "" 1716 + 1717 + rp.notifier.NewRepo(r.Context(), repo) 1502 1718 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1503 - return 1504 1719 } 1505 1720 } 1506 1721 1722 + // this is used to rollback changes made to the PDS 1723 + // 1724 + // it is a no-op if the provided ATURI is empty 1725 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1726 + if aturi == "" { 1727 + return nil 1728 + } 1729 + 1730 + parsed := syntax.ATURI(aturi) 1731 + 1732 + collection := parsed.Collection().String() 1733 + repo := parsed.Authority().String() 1734 + rkey := parsed.RecordKey().String() 1735 + 1736 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1737 + Collection: collection, 1738 + Repo: repo, 1739 + Rkey: rkey, 1740 + }) 1741 + return err 1742 + } 1743 + 1507 1744 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1508 1745 user := rp.oauth.GetUser(r) 1509 1746 f, err := rp.repoResolver.Resolve(r) ··· 1512 1749 return 1513 1750 } 1514 1751 1515 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1752 + scheme := "http" 1753 + if !rp.config.Core.Dev { 1754 + scheme = "https" 1755 + } 1756 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1757 + xrpcc := &indigoxrpc.Client{ 1758 + Host: host, 1759 + } 1760 + 1761 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1762 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1516 1763 if err != nil { 1517 - log.Printf("failed to create unsigned client for %s", f.Knot) 1518 - rp.pages.Error503(w) 1764 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1765 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1766 + rp.pages.Error503(w) 1767 + return 1768 + } 1769 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1519 1770 return 1520 1771 } 1521 1772 1522 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1523 - if err != nil { 1773 + var branchResult types.RepoBranchesResponse 1774 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1775 + log.Println("failed to decode XRPC branches response", err) 1524 1776 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1525 - log.Println("failed to reach knotserver", err) 1526 1777 return 1527 1778 } 1528 - branches := result.Branches 1779 + branches := branchResult.Branches 1529 1780 1530 1781 sortBranches(branches) 1531 1782 ··· 1549 1800 head = queryHead 1550 1801 } 1551 1802 1552 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1803 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1553 1804 if err != nil { 1805 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1806 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1807 + rp.pages.Error503(w) 1808 + return 1809 + } 1554 1810 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1555 - log.Println("failed to reach knotserver", err) 1811 + return 1812 + } 1813 + 1814 + var tags types.RepoTagsResponse 1815 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1816 + log.Println("failed to decode XRPC tags response", err) 1817 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1556 1818 return 1557 1819 } 1558 1820 ··· 1604 1866 return 1605 1867 } 1606 1868 1607 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1869 + scheme := "http" 1870 + if !rp.config.Core.Dev { 1871 + scheme = "https" 1872 + } 1873 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1874 + xrpcc := &indigoxrpc.Client{ 1875 + Host: host, 1876 + } 1877 + 1878 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1879 + 1880 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1608 1881 if err != nil { 1609 - log.Printf("failed to create unsigned client for %s", f.Knot) 1610 - rp.pages.Error503(w) 1882 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1883 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1884 + rp.pages.Error503(w) 1885 + return 1886 + } 1887 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1888 + return 1889 + } 1890 + 1891 + var branches types.RepoBranchesResponse 1892 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1893 + log.Println("failed to decode XRPC branches response", err) 1894 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1611 1895 return 1612 1896 } 1613 1897 1614 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1898 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1615 1899 if err != nil { 1900 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1901 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1902 + rp.pages.Error503(w) 1903 + return 1904 + } 1616 1905 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1617 - log.Println("failed to reach knotserver", err) 1906 + return 1907 + } 1908 + 1909 + var tags types.RepoTagsResponse 1910 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1911 + log.Println("failed to decode XRPC tags response", err) 1912 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1618 1913 return 1619 1914 } 1620 1915 1621 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1916 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1622 1917 if err != nil { 1918 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1919 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1920 + rp.pages.Error503(w) 1921 + return 1922 + } 1623 1923 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 - log.Println("failed to reach knotserver", err) 1625 1924 return 1626 1925 } 1627 1926 1628 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1629 - if err != nil { 1927 + var formatPatch types.RepoFormatPatchResponse 1928 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1929 + log.Println("failed to decode XRPC compare response", err) 1630 1930 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1631 - log.Println("failed to compare", err) 1632 1931 return 1633 1932 } 1933 + 1634 1934 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1635 1935 1636 1936 repoinfo := f.RepoInfo(user)
+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
··· 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 ""
+148
appview/serververify/verify.go
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 + "tangled.sh/tangled.sh/core/rbac" 13 + ) 14 + 15 + var ( 16 + FetchError = errors.New("failed to fetch owner") 17 + ) 18 + 19 + // fetchOwner fetches the owner DID from a server's /owner endpoint 20 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 21 + scheme := "https" 22 + if dev { 23 + scheme = "http" 24 + } 25 + 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 29 + } 30 + 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 34 + } 35 + 36 + return res.Owner, nil 37 + } 38 + 39 + type OwnerMismatch struct { 40 + expected string 41 + observed string 42 + } 43 + 44 + func (e *OwnerMismatch) Error() string { 45 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 46 + } 47 + 48 + // RunVerification verifies that the server at the given domain has the expected owner 49 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 50 + observedOwner, err := fetchOwner(ctx, domain, dev) 51 + if err != nil { 52 + return err 53 + } 54 + 55 + if observedOwner != expectedOwner { 56 + return &OwnerMismatch{ 57 + expected: expectedOwner, 58 + observed: observedOwner, 59 + } 60 + } 61 + 62 + return nil 63 + } 64 + 65 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 66 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 67 + tx, err := d.Begin() 68 + if err != nil { 69 + return 0, fmt.Errorf("failed to create txn: %w", err) 70 + } 71 + defer func() { 72 + tx.Rollback() 73 + e.E.LoadPolicy() 74 + }() 75 + 76 + // mark this spindle as verified in the db 77 + rowId, err := db.VerifySpindle( 78 + tx, 79 + db.FilterEq("owner", owner), 80 + db.FilterEq("instance", instance), 81 + ) 82 + if err != nil { 83 + return 0, fmt.Errorf("failed to write to DB: %w", err) 84 + } 85 + 86 + err = e.AddSpindleOwner(instance, owner) 87 + if err != nil { 88 + return 0, fmt.Errorf("failed to update ACL: %w", err) 89 + } 90 + 91 + err = tx.Commit() 92 + if err != nil { 93 + return 0, fmt.Errorf("failed to commit txn: %w", err) 94 + } 95 + 96 + err = e.E.SavePolicy() 97 + if err != nil { 98 + return 0, fmt.Errorf("failed to update ACL: %w", err) 99 + } 100 + 101 + return rowId, nil 102 + } 103 + 104 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 105 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 106 + tx, err := d.BeginTx(context.Background(), nil) 107 + if err != nil { 108 + return fmt.Errorf("failed to start tx: %w", err) 109 + } 110 + defer func() { 111 + tx.Rollback() 112 + e.E.LoadPolicy() 113 + }() 114 + 115 + // mark as registered 116 + err = db.MarkRegistered( 117 + tx, 118 + db.FilterEq("did", owner), 119 + db.FilterEq("domain", domain), 120 + ) 121 + if err != nil { 122 + return fmt.Errorf("failed to register domain: %w", err) 123 + } 124 + 125 + // add basic acls for this domain 126 + err = e.AddKnot(domain) 127 + if err != nil { 128 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 129 + } 130 + 131 + // add this did as owner of this domain 132 + err = e.AddKnotOwner(domain, owner) 133 + if err != nil { 134 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 135 + } 136 + 137 + err = tx.Commit() 138 + if err != nil { 139 + return fmt.Errorf("failed to commit changes: %w", err) 140 + } 141 + 142 + err = e.E.SavePolicy() 143 + if err != nil { 144 + return fmt.Errorf("failed to update ACLs: %w", err) 145 + } 146 + 147 + return nil 148 + }
+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 }
+10 -22
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 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 19 20 "tangled.sh/tangled.sh/core/idresolver" 20 21 "tangled.sh/tangled.sh/core/rbac" 21 22 "tangled.sh/tangled.sh/core/tid" ··· 113 114 return 114 115 } 115 116 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 117 // organize repos by did 129 118 repoMap := make(map[string][]db.Repo) 130 119 for _, r := range repos { ··· 136 125 Spindle: spindle, 137 126 Members: members, 138 127 Repos: repoMap, 139 - DidHandleMap: didHandleMap, 140 128 }) 141 129 } 142 130 ··· 240 228 } 241 229 242 230 // begin verification 243 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 231 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 232 if err != nil { 245 233 l.Error("verification failed", "err", err) 246 234 s.Pages.HxRefresh(w) 247 235 return 248 236 } 249 237 250 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 238 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 251 239 if err != nil { 252 240 l.Error("failed to mark verified", "err", err) 253 241 s.Pages.HxRefresh(w) ··· 413 401 } 414 402 415 403 // begin verification 416 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 404 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 417 405 if err != nil { 418 406 l.Error("verification failed", "err", err) 419 407 420 - if errors.Is(err, verify.FetchError) { 421 - s.Pages.Notice(w, noticeId, err.Error()) 408 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 409 + s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 422 410 return 423 411 } 424 412 425 - if e, ok := err.(*verify.OwnerMismatch); ok { 413 + if e, ok := err.(*serververify.OwnerMismatch); ok { 426 414 s.Pages.Notice(w, noticeId, e.Error()) 427 415 return 428 416 } ··· 431 419 return 432 420 } 433 421 434 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 422 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 435 423 if err != nil { 436 424 l.Error("failed to mark verified", "err", err) 437 425 s.Pages.Notice(w, noticeId, err.Error()) ··· 455 443 } 456 444 457 445 w.Header().Set("HX-Reswap", "outerHTML") 458 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 459 447 } 460 448 461 449 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
-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
··· 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
··· 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
+420 -113
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) 42 83 } 43 84 44 - profile, err := db.GetProfile(s.db, ident.DID.String()) 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 + ) 45 93 if err != nil { 46 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 94 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 47 95 } 48 96 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) 117 + if err != nil { 118 + l.Error("failed to build profile card", "err", err) 119 + s.pages.Error500(w) 120 + return 121 + } 122 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 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) 161 + if err != nil { 162 + l.Error("failed to create timeline", "err", err) 163 + } 164 + 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 182 + } 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)) 86 255 if err != nil { 87 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 256 + l.Error("failed to get strings", "err", err) 257 + s.pages.Error500(w) 258 + return 88 259 } 89 260 90 - var didsToResolve []string 91 - for _, r := range collaboratingRepos { 92 - didsToResolve = append(didsToResolve, r.Did) 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 93 283 } 94 - for _, byMonth := range timeline.ByMonth { 95 - for _, pe := range byMonth.PullEvents.Items { 96 - didsToResolve = append(didsToResolve, pe.Repo.Did) 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) 168 395 if err != nil { 169 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 396 + s.pages.Error500(w) 397 + return 398 + } 399 + 400 + if feed == nil { 401 + return 170 402 } 171 403 172 - repos, err := db.GetRepos( 173 - s.db, 174 - 0, 175 - db.FilterEq("did", ident.DID.String()), 176 - ) 404 + atom, err := feed.ToAtom() 177 405 if err != nil { 178 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 406 + s.pages.Error500(w) 407 + return 179 408 } 180 409 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()) 185 - } 410 + w.Header().Set("content-type", "application/atom+xml") 411 + w.Write([]byte(atom)) 412 + } 186 413 187 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 414 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 415 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 188 416 if err != nil { 189 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 417 + return nil, err 190 418 } 191 419 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 - }, 420 + author := &feeds.Author{ 421 + Name: fmt.Sprintf("@%s", id.Handle), 422 + } 423 + 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, 430 + } 431 + 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 + } 442 + } 443 + 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.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.Repo.Name), 503 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.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) { ··· 379 701 log.Printf("getting profile data for %s: %s", user.Did, err) 380 702 } 381 703 382 - repos, err := db.GetAllReposByDid(s.db, user.Did) 704 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 383 705 if err != nil { 384 706 log.Printf("getting repos for %s: %s", user.Did, err) 385 707 } ··· 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 }
+22 -8
appview/state/router.go
··· 32 32 s.pages, 33 33 ) 34 34 35 + router.Get("/favicon.svg", s.Favicon) 36 + router.Get("/favicon.ico", s.Favicon) 37 + 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)) ··· 99 111 100 112 r.Handle("/static/*", s.pages.Static()) 101 113 102 - r.Get("/", s.Timeline) 114 + r.Get("/", s.HomeOrTimeline) 115 + r.Get("/timeline", s.Timeline) 116 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 103 117 104 118 r.Route("/repo", func(r chi.Router) { 105 119 r.Route("/new", func(r chi.Router) { ··· 135 149 136 150 r.Mount("/settings", s.SettingsRouter()) 137 151 r.Mount("/strings", s.StringsRouter(mw)) 138 - r.Mount("/knots", s.KnotsRouter(mw)) 152 + r.Mount("/knots", s.KnotsRouter()) 139 153 r.Mount("/spindles", s.SpindlesRouter()) 140 154 r.Mount("/signup", s.SignupRouter()) 141 155 r.Mount("/", s.OAuthRouter()) ··· 183 197 return spindles.Router() 184 198 } 185 199 186 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 200 + func (s *State) KnotsRouter() http.Handler { 187 201 logger := log.New("knots") 188 202 189 203 knots := &knots.Knots{ ··· 197 211 Logger: logger, 198 212 } 199 213 200 - return knots.Router(mw) 214 + return knots.Router() 201 215 } 202 216 203 217 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { ··· 218 232 } 219 233 220 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 221 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 222 236 return issues.Router(mw) 223 237 } 224 238
+197 -70
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 + "tangled.sh/tangled.sh/core/appview/validator" 32 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 28 33 "tangled.sh/tangled.sh/core/eventconsumer" 29 34 "tangled.sh/tangled.sh/core/idresolver" 30 35 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 36 tlog "tangled.sh/tangled.sh/core/log" 33 37 "tangled.sh/tangled.sh/core/rbac" 34 38 "tangled.sh/tangled.sh/core/tid" 39 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 35 40 ) 36 41 37 42 type State struct { ··· 48 53 repoResolver *reporesolver.RepoResolver 49 54 knotstream *eventconsumer.Consumer 50 55 spindlestream *eventconsumer.Consumer 56 + logger *slog.Logger 57 + validator *validator.Validator 51 58 } 52 59 53 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 61 68 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 69 } 63 70 64 - pgs := pages.NewPages(config) 65 - 66 71 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 72 if err != nil { 68 73 log.Printf("failed to create redis resolver: %v", err) 69 74 res = idresolver.DefaultResolver() 70 75 } 71 76 77 + pgs := pages.NewPages(config, res) 72 78 cache := cache.New(config.Redis.Addr) 73 79 sess := session.New(cache) 74 - 75 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 76 82 77 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 78 84 if err != nil { ··· 94 100 tangled.SpindleMemberNSID, 95 101 tangled.SpindleNSID, 96 102 tangled.StringNSID, 103 + tangled.RepoIssueNSID, 104 + tangled.RepoIssueCommentNSID, 97 105 }, 98 106 nil, 99 107 slog.Default(), ··· 114 122 IdResolver: res, 115 123 Config: config, 116 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 117 126 } 118 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 119 128 if err != nil { ··· 152 161 repoResolver, 153 162 knotstream, 154 163 spindlestream, 164 + slog.Default(), 165 + validator, 155 166 } 156 167 157 168 return state, nil 158 169 } 159 170 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 174 + } 175 + 176 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 177 + w.Header().Set("Content-Type", "image/svg+xml") 178 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 179 + w.Header().Set("ETag", `"favicon-svg-v1"`) 180 + 181 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 182 + w.WriteHeader(http.StatusNotModified) 183 + return 184 + } 185 + 186 + s.pages.Favicon(w) 187 + } 188 + 160 189 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 161 190 user := s.oauth.GetUser(r) 162 191 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 171 200 }) 172 201 } 173 202 203 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 + if s.oauth.GetUser(r) != nil { 205 + s.Timeline(w, r) 206 + return 207 + } 208 + s.Home(w, r) 209 + } 210 + 174 211 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 175 212 user := s.oauth.GetUser(r) 176 213 177 - timeline, err := db.MakeTimeline(s.db) 214 + timeline, err := db.MakeTimeline(s.db, 50) 178 215 if err != nil { 179 216 log.Println(err) 180 217 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 181 218 } 182 219 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 - } 220 + repos, err := db.GetTopStarredReposLastWeek(s.db) 221 + if err != nil { 222 + log.Println(err) 223 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 224 + return 207 225 } 208 226 209 227 s.pages.Timeline(w, pages.TimelineParams{ 210 228 LoggedInUser: user, 211 229 Timeline: timeline, 212 - DidHandleMap: didHandleMap, 230 + Repos: repos, 231 + }) 232 + } 233 + 234 + func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 235 + user := s.oauth.GetUser(r) 236 + l := s.logger.With("handler", "UpgradeBanner") 237 + l = l.With("did", user.Did) 238 + l = l.With("handle", user.Handle) 239 + 240 + regs, err := db.GetRegistrations( 241 + s.db, 242 + db.FilterEq("did", user.Did), 243 + db.FilterEq("needs_upgrade", 1), 244 + ) 245 + if err != nil { 246 + l.Error("non-fatal: failed to get registrations", "err", err) 247 + } 248 + 249 + spindles, err := db.GetSpindles( 250 + s.db, 251 + db.FilterEq("owner", user.Did), 252 + db.FilterEq("needs_upgrade", 1), 253 + ) 254 + if err != nil { 255 + l.Error("non-fatal: failed to get spindles", "err", err) 256 + } 257 + 258 + if regs == nil && spindles == nil { 259 + return 260 + } 261 + 262 + s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 263 + Registrations: regs, 264 + Spindles: spindles, 213 265 }) 266 + } 214 267 215 - return 268 + func (s *State) Home(w http.ResponseWriter, r *http.Request) { 269 + timeline, err := db.MakeTimeline(s.db, 5) 270 + if err != nil { 271 + log.Println(err) 272 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 273 + return 274 + } 275 + 276 + repos, err := db.GetTopStarredReposLastWeek(s.db) 277 + if err != nil { 278 + log.Println(err) 279 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 280 + return 281 + } 282 + 283 + s.pages.Home(w, pages.TimelineParams{ 284 + LoggedInUser: nil, 285 + Timeline: timeline, 286 + Repos: repos, 287 + }) 216 288 } 217 289 218 290 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 243 315 244 316 for _, k := range pubKeys { 245 317 key := strings.TrimRight(k.Key, "\n") 246 - w.Write([]byte(fmt.Sprintln(key))) 318 + fmt.Fprintln(w, key) 247 319 } 248 320 } 249 321 ··· 279 351 return nil 280 352 } 281 353 354 + func stripGitExt(name string) string { 355 + return strings.TrimSuffix(name, ".git") 356 + } 357 + 282 358 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 283 359 switch r.Method { 284 360 case http.MethodGet: ··· 295 371 }) 296 372 297 373 case http.MethodPost: 374 + l := s.logger.With("handler", "NewRepo") 375 + 298 376 user := s.oauth.GetUser(r) 377 + l = l.With("did", user.Did) 378 + l = l.With("handle", user.Handle) 299 379 380 + // form validation 300 381 domain := r.FormValue("domain") 301 382 if domain == "" { 302 383 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 303 384 return 304 385 } 386 + l = l.With("knot", domain) 305 387 306 388 repoName := r.FormValue("name") 307 389 if repoName == "" { ··· 313 395 s.pages.Notice(w, "repo", err.Error()) 314 396 return 315 397 } 398 + repoName = stripGitExt(repoName) 399 + l = l.With("repoName", repoName) 316 400 317 401 defaultBranch := r.FormValue("branch") 318 402 if defaultBranch == "" { 319 403 defaultBranch = "main" 320 404 } 405 + l = l.With("defaultBranch", defaultBranch) 321 406 322 407 description := r.FormValue("description") 323 408 409 + // ACL validation 324 410 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 325 411 if err != nil || !ok { 412 + l.Info("unauthorized") 326 413 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 327 414 return 328 415 } 329 416 417 + // Check for existing repos 330 418 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 331 419 if err == nil && existingRepo != nil { 332 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 420 + l.Info("repo exists") 421 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 333 422 return 334 423 } 335 424 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.") 345 - return 346 - } 347 - 425 + // create atproto record for this repo 348 426 rkey := tid.TID() 349 427 repo := &db.Repo{ 350 428 Did: user.Did, ··· 356 434 357 435 xrpcClient, err := s.oauth.AuthorizedClient(r) 358 436 if err != nil { 437 + l.Info("PDS write failed", "err", err) 359 438 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 360 439 return 361 440 } ··· 374 453 }}, 375 454 }) 376 455 if err != nil { 377 - log.Printf("failed to create record: %s", err) 456 + l.Info("PDS write failed", "err", err) 378 457 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 379 458 return 380 459 } 381 - log.Println("created repo record: ", atresp.Uri) 460 + 461 + aturi := atresp.Uri 462 + l = l.With("aturi", aturi) 463 + l.Info("wrote to PDS") 382 464 383 465 tx, err := s.db.BeginTx(r.Context(), nil) 384 466 if err != nil { 385 - log.Println(err) 467 + l.Info("txn failed", "err", err) 386 468 s.pages.Notice(w, "repo", "Failed to save repository information.") 387 469 return 388 470 } 389 - defer func() { 390 - tx.Rollback() 391 - err = s.enforcer.E.LoadPolicy() 392 - if err != nil { 393 - log.Println("failed to rollback policies") 471 + 472 + // The rollback function reverts a few things on failure: 473 + // - the pending txn 474 + // - the ACLs 475 + // - the atproto record created 476 + rollback := func() { 477 + err1 := tx.Rollback() 478 + err2 := s.enforcer.E.LoadPolicy() 479 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 480 + 481 + // ignore txn complete errors, this is okay 482 + if errors.Is(err1, sql.ErrTxDone) { 483 + err1 = nil 484 + } 485 + 486 + if errs := errors.Join(err1, err2, err3); errs != nil { 487 + l.Error("failed to rollback changes", "errs", errs) 488 + return 394 489 } 395 - }() 490 + } 491 + defer rollback() 396 492 397 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 493 + client, err := s.oauth.ServiceClient( 494 + r, 495 + oauth.WithService(domain), 496 + oauth.WithLxm(tangled.RepoCreateNSID), 497 + oauth.WithDev(s.config.Core.Dev), 498 + ) 398 499 if err != nil { 399 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 500 + l.Error("service auth failed", "err", err) 501 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 400 502 return 401 503 } 402 504 403 - switch resp.StatusCode { 404 - case http.StatusConflict: 405 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 505 + xe := tangled.RepoCreate( 506 + r.Context(), 507 + client, 508 + &tangled.RepoCreate_Input{ 509 + Rkey: rkey, 510 + }, 511 + ) 512 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 513 + l.Error("xrpc error", "xe", xe) 514 + s.pages.Notice(w, "repo", err.Error()) 406 515 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 516 } 412 517 413 - repo.AtUri = atresp.Uri 414 518 err = db.AddRepo(tx, repo) 415 519 if err != nil { 416 - log.Println(err) 520 + l.Error("db write failed", "err", err) 417 521 s.pages.Notice(w, "repo", "Failed to save repository information.") 418 522 return 419 523 } ··· 422 526 p, _ := securejoin.SecureJoin(user.Did, repoName) 423 527 err = s.enforcer.AddRepo(user.Did, domain, p) 424 528 if err != nil { 425 - log.Println(err) 529 + l.Error("acl setup failed", "err", err) 426 530 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 427 531 return 428 532 } 429 533 430 534 err = tx.Commit() 431 535 if err != nil { 432 - log.Println("failed to commit changes", err) 536 + l.Error("txn commit failed", "err", err) 433 537 http.Error(w, err.Error(), http.StatusInternalServerError) 434 538 return 435 539 } 436 540 437 541 err = s.enforcer.E.SavePolicy() 438 542 if err != nil { 439 - log.Println("failed to update ACLs", err) 543 + l.Error("acl save failed", "err", err) 440 544 http.Error(w, err.Error(), http.StatusInternalServerError) 441 545 return 442 546 } 547 + 548 + // reset the ATURI because the transaction completed successfully 549 + aturi = "" 443 550 444 551 s.notifier.NewRepo(r.Context(), repo) 552 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 553 + } 554 + } 445 555 446 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 447 - return 556 + // this is used to rollback changes made to the PDS 557 + // 558 + // it is a no-op if the provided ATURI is empty 559 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 560 + if aturi == "" { 561 + return nil 448 562 } 563 + 564 + parsed := syntax.ATURI(aturi) 565 + 566 + collection := parsed.Collection().String() 567 + repo := parsed.Authority().String() 568 + rkey := parsed.RecordKey().String() 569 + 570 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 571 + Collection: collection, 572 + Repo: repo, 573 + Rkey: rkey, 574 + }) 575 + return err 449 576 }
+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
+53
appview/validator/issue.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.sh/tangled.sh/core/appview/db" 8 + ) 9 + 10 + func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + // if comments have parents, only ingest ones that are 1 level deep 12 + if comment.ReplyTo != nil { 13 + parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 14 + if err != nil { 15 + return fmt.Errorf("failed to fetch parent comment: %w", err) 16 + } 17 + if len(parents) != 1 { 18 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 19 + } 20 + 21 + // depth check 22 + parent := parents[0] 23 + if parent.ReplyTo != nil { 24 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 25 + } 26 + } 27 + 28 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 29 + return fmt.Errorf("body is empty after HTML sanitization") 30 + } 31 + 32 + return nil 33 + } 34 + 35 + func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + if issue.Title == "" { 37 + return fmt.Errorf("issue title is empty") 38 + } 39 + 40 + if issue.Body == "" { 41 + return fmt.Errorf("issue body is empty") 42 + } 43 + 44 + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 45 + return fmt.Errorf("title is empty after HTML sanitization") 46 + } 47 + 48 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 49 + return fmt.Errorf("body is empty after HTML sanitization") 50 + } 51 + 52 + return nil 53 + }
+18
appview/validator/validator.go
··· 1 + package validator 2 + 3 + import ( 4 + "tangled.sh/tangled.sh/core/appview/db" 5 + "tangled.sh/tangled.sh/core/appview/pages/markup" 6 + ) 7 + 8 + type Validator struct { 9 + db *db.DB 10 + sanitizer markup.Sanitizer 11 + } 12 + 13 + func New(db *db.DB) *Validator { 14 + return &Validator{ 15 + db: db, 16 + sanitizer: markup.NewSanitizer(), 17 + } 18 + }
+31
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 6 7 "io" 8 + "net/http" 7 9 8 10 "github.com/bluesky-social/indigo/api/atproto" 9 11 "github.com/bluesky-social/indigo/xrpc" 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 13 oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 + ) 15 + 16 + var ( 17 + ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") 18 + ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") 19 + ErrXrpcFailed = errors.New("xrpc request failed") 20 + ErrXrpcInvalid = errors.New("invalid xrpc request") 11 21 ) 12 22 13 23 type Client struct { ··· 102 112 103 113 return &out, nil 104 114 } 115 + 116 + // produces a more manageable error 117 + func HandleXrpcErr(err error) error { 118 + if err == nil { 119 + return nil 120 + } 121 + 122 + var xrpcerr *indigoxrpc.Error 123 + if ok := errors.As(err, &xrpcerr); !ok { 124 + return ErrXrpcInvalid 125 + } 126 + 127 + switch xrpcerr.StatusCode { 128 + case http.StatusNotFound: 129 + return ErrXrpcUnsupported 130 + case http.StatusUnauthorized: 131 + return ErrXrpcUnauthorized 132 + default: 133 + return ErrXrpcFailed 134 + } 135 + }
+3
cmd/appview/main.go
··· 23 23 } 24 24 25 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 26 29 27 30 if err != nil { 28 31 log.Fatal(err)
+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
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+1 -1
cmd/punchcardPopulate/main.go
··· 11 11 ) 12 12 13 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 15 if err != nil { 16 16 log.Fatal("Failed to open database:", err) 17 17 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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=
+84 -7
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 32 40 font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 48 + font-weight: normal; 49 + font-style: italic; 50 + font-display: swap; 51 + } 52 + 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 33 65 font-style: italic; 34 66 font-display: swap; 35 67 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 59 90 } 60 91 61 92 label { 62 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 63 94 } 64 95 input { 65 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; ··· 69 100 } 70 101 details summary::-webkit-details-marker { 71 102 display: none; 103 + } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 72 107 } 73 108 } 74 109 ··· 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
··· 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
··· 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 - }
-250
knotclient/unsigned.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "strconv" 12 - "time" 13 - 14 - "tangled.sh/tangled.sh/core/types" 15 - ) 16 - 17 - type UnsignedClient struct { 18 - Url *url.URL 19 - client *http.Client 20 - } 21 - 22 - func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 23 - client := &http.Client{ 24 - Timeout: 5 * time.Second, 25 - } 26 - 27 - scheme := "https" 28 - if dev { 29 - scheme = "http" 30 - } 31 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 32 - if err != nil { 33 - return nil, err 34 - } 35 - 36 - unsignedClient := &UnsignedClient{ 37 - client: client, 38 - Url: url, 39 - } 40 - 41 - return unsignedClient, nil 42 - } 43 - 44 - func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) { 45 - reqUrl := us.Url.JoinPath(endpoint) 46 - 47 - // add query parameters 48 - if query != nil { 49 - reqUrl.RawQuery = query.Encode() 50 - } 51 - 52 - return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body)) 53 - } 54 - 55 - func do[T any](us *UnsignedClient, req *http.Request) (*T, error) { 56 - resp, err := us.client.Do(req) 57 - if err != nil { 58 - return nil, err 59 - } 60 - defer resp.Body.Close() 61 - 62 - body, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - log.Printf("Error reading response body: %v", err) 65 - return nil, err 66 - } 67 - 68 - var result T 69 - err = json.Unmarshal(body, &result) 70 - if err != nil { 71 - log.Printf("Error unmarshalling response body: %v", err) 72 - return nil, err 73 - } 74 - 75 - return &result, nil 76 - } 77 - 78 - func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) { 79 - const ( 80 - Method = "GET" 81 - ) 82 - 83 - endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 84 - if ref == "" { 85 - endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 86 - } 87 - 88 - req, err := us.newRequest(Method, endpoint, nil, nil) 89 - if err != nil { 90 - return nil, err 91 - } 92 - 93 - return do[types.RepoIndexResponse](us, req) 94 - } 95 - 96 - func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) { 97 - const ( 98 - Method = "GET" 99 - ) 100 - 101 - endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref)) 102 - 103 - query := url.Values{} 104 - query.Add("page", strconv.Itoa(page)) 105 - query.Add("per_page", strconv.Itoa(60)) 106 - 107 - req, err := us.newRequest(Method, endpoint, query, nil) 108 - if err != nil { 109 - return nil, err 110 - } 111 - 112 - return do[types.RepoLogResponse](us, req) 113 - } 114 - 115 - func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) { 116 - const ( 117 - Method = "GET" 118 - ) 119 - 120 - endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 121 - 122 - req, err := us.newRequest(Method, endpoint, nil, nil) 123 - if err != nil { 124 - return nil, err 125 - } 126 - 127 - return do[types.RepoBranchesResponse](us, req) 128 - } 129 - 130 - func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 131 - const ( 132 - Method = "GET" 133 - ) 134 - 135 - endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName) 136 - 137 - req, err := us.newRequest(Method, endpoint, nil, nil) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - return do[types.RepoTagsResponse](us, req) 143 - } 144 - 145 - func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) { 146 - const ( 147 - Method = "GET" 148 - ) 149 - 150 - endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch)) 151 - 152 - req, err := us.newRequest(Method, endpoint, nil, nil) 153 - if err != nil { 154 - return nil, err 155 - } 156 - 157 - return do[types.RepoBranchResponse](us, req) 158 - } 159 - 160 - func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) { 161 - const ( 162 - Method = "GET" 163 - ) 164 - 165 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 166 - 167 - req, err := us.newRequest(Method, endpoint, nil, nil) 168 - if err != nil { 169 - return nil, err 170 - } 171 - 172 - resp, err := us.client.Do(req) 173 - if err != nil { 174 - return nil, err 175 - } 176 - defer resp.Body.Close() 177 - 178 - var defaultBranch types.RepoDefaultBranchResponse 179 - if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil { 180 - return nil, err 181 - } 182 - 183 - return &defaultBranch, nil 184 - } 185 - 186 - func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 187 - const ( 188 - Method = "GET" 189 - Endpoint = "/capabilities" 190 - ) 191 - 192 - req, err := us.newRequest(Method, Endpoint, nil, nil) 193 - if err != nil { 194 - return nil, err 195 - } 196 - 197 - resp, err := us.client.Do(req) 198 - if err != nil { 199 - return nil, err 200 - } 201 - defer resp.Body.Close() 202 - 203 - var capabilities types.Capabilities 204 - if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 205 - return nil, err 206 - } 207 - 208 - return &capabilities, nil 209 - } 210 - 211 - func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 212 - const ( 213 - Method = "GET" 214 - ) 215 - 216 - endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 217 - 218 - req, err := us.newRequest(Method, endpoint, nil, nil) 219 - if err != nil { 220 - return nil, fmt.Errorf("Failed to create request.") 221 - } 222 - 223 - compareResp, err := us.client.Do(req) 224 - if err != nil { 225 - return nil, fmt.Errorf("Failed to create request.") 226 - } 227 - defer compareResp.Body.Close() 228 - 229 - switch compareResp.StatusCode { 230 - case 404: 231 - case 400: 232 - return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 233 - } 234 - 235 - respBody, err := io.ReadAll(compareResp.Body) 236 - if err != nil { 237 - log.Println("failed to compare across branches") 238 - return nil, fmt.Errorf("Failed to compare branches.") 239 - } 240 - defer compareResp.Body.Close() 241 - 242 - var formatPatchResponse types.RepoFormatPatchResponse 243 - err = json.Unmarshal(respBody, &formatPatchResponse) 244 - if err != nil { 245 - log.Println("failed to unmarshal format-patch response", err) 246 - return nil, fmt.Errorf("failed to compare branches.") 247 - } 248 - 249 - return &formatPatchResponse, nil 250 - }
+8 -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. 27 27 Dev bool `env:"DEV, default=false"` 28 28 } 29 29 30 + type Git struct { 31 + // user name & email used as committer 32 + UserName string `env:"USER_NAME, default=Tangled"` 33 + UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"` 34 + } 35 + 30 36 func (s Server) Did() syntax.DID { 31 37 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 38 } ··· 34 40 type Config struct { 35 41 Repo Repo `env:",prefix=KNOT_REPO_"` 36 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 37 44 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 38 45 } 39 46
+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 );
+40
knotserver/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 + "strconv" 4 5 "time" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 100 101 return keys, nil 101 102 } 103 + 104 + func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { 105 + var keys []PublicKey 106 + 107 + offset := 0 108 + if cursor != "" { 109 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 110 + offset = o 111 + } 112 + } 113 + 114 + query := `select key, did, created from public_keys order by created desc limit ? offset ?` 115 + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results 116 + if err != nil { 117 + return nil, "", err 118 + } 119 + defer rows.Close() 120 + 121 + for rows.Next() { 122 + var publicKey PublicKey 123 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 124 + return nil, "", err 125 + } 126 + keys = append(keys, publicKey) 127 + } 128 + 129 + if err := rows.Err(); err != nil { 130 + return nil, "", err 131 + } 132 + 133 + // check if there are more results for pagination 134 + var nextCursor string 135 + if len(keys) > limit { 136 + keys = keys[:limit] // remove the extra item 137 + nextCursor = strconv.Itoa(offset + limit) 138 + } 139 + 140 + return keys, nextCursor, nil 141 + }
+2 -2
knotserver/events.go
··· 15 15 WriteBufferSize: 1024, 16 16 } 17 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 19 l := h.l.With("handler", "OpLog") 20 20 l.Debug("received new connection") 21 21 ··· 83 83 } 84 84 } 85 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 87 events, err := h.db.GetEvents(*cursor) 88 88 if err != nil { 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-48
knotserver/file.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "net/http" 8 - "strings" 9 - 10 - "tangled.sh/tangled.sh/core/types" 11 - ) 12 - 13 - func countLines(r io.Reader) (int, error) { 14 - buf := make([]byte, 32*1024) 15 - bufLen := 0 16 - count := 0 17 - nl := []byte{'\n'} 18 - 19 - for { 20 - c, err := r.Read(buf) 21 - if c > 0 { 22 - bufLen += c 23 - } 24 - count += bytes.Count(buf[:c], nl) 25 - 26 - switch { 27 - case err == io.EOF: 28 - /* handle last line not having a newline at the end */ 29 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 30 - count++ 31 - } 32 - return count, nil 33 - case err != nil: 34 - return 0, err 35 - } 36 - } 37 - } 38 - 39 - func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) { 40 - lc, err := countLines(strings.NewReader(resp.Contents)) 41 - if err != nil { 42 - // Non-fatal, we'll just skip showing line numbers in the template. 43 - l.Warn("counting lines", "error", err) 44 - } 45 - 46 - resp.Lines = lc 47 - writeJSON(w, resp) 48 - }
+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
+58 -72
knotserver/git/merge.go
··· 12 12 "github.com/dgraph-io/ristretto" 13 13 "github.com/go-git/go-git/v5" 14 14 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 15 ) 17 16 18 17 type MergeCheckCache struct { ··· 86 85 87 86 // MergeOptions specifies the configuration for a merge operation 88 87 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 88 + CommitMessage string 89 + CommitBody string 90 + AuthorName string 91 + AuthorEmail string 92 + CommitterName string 93 + CommitterEmail string 94 + FormatPatch bool 94 95 } 95 96 96 97 func (e ErrMerge) Error() string { ··· 143 144 return tmpDir, nil 144 145 } 145 146 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 147 148 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 149 149 150 - if checkOnly { 151 - cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 152 - } else { 153 - // if patch is a format-patch, apply using 'git am' 154 - if opts.FormatPatch { 155 - amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 156 - amCmd.Stderr = &stderr 157 - if err := amCmd.Run(); err != nil { 158 - return fmt.Errorf("patch application failed: %s", stderr.String()) 159 - } 160 - return nil 150 + cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 151 + cmd.Stderr = &stderr 152 + 153 + if err := cmd.Run(); err != nil { 154 + conflicts := parseGitApplyErrors(stderr.String()) 155 + return &ErrMerge{ 156 + Message: "patch cannot be applied cleanly", 157 + Conflicts: conflicts, 158 + HasConflict: len(conflicts) > 0, 159 + OtherError: err, 161 160 } 162 - 163 - // else, apply using 'git apply' and commit it manually 164 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 165 - if opts != nil { 166 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 167 - applyCmd.Stderr = &stderr 168 - if err := applyCmd.Run(); err != nil { 169 - return fmt.Errorf("patch application failed: %s", stderr.String()) 170 - } 161 + } 162 + return nil 163 + } 171 164 172 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 173 - if err := stageCmd.Run(); err != nil { 174 - return fmt.Errorf("failed to stage changes: %w", err) 175 - } 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 + var stderr bytes.Buffer 167 + var cmd *exec.Cmd 176 168 177 - commitArgs := []string{"-C", tmpDir, "commit"} 169 + // configure default git user before merge 170 + exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 + exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 178 173 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 174 + // if patch is a format-patch, apply using 'git am' 175 + if opts.FormatPatch { 176 + cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 + } else { 178 + // else, apply using 'git apply' and commit it manually 179 + applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 + applyCmd.Stderr = &stderr 181 + if err := applyCmd.Run(); err != nil { 182 + return fmt.Errorf("patch application failed: %s", stderr.String()) 183 + } 182 184 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 185 + stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 + if err := stageCmd.Run(); err != nil { 187 + return fmt.Errorf("failed to stage changes: %w", err) 188 + } 186 189 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 190 + commitArgs := []string{"-C", tmpDir, "commit"} 190 191 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 194 195 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 + if authorName != "" && authorEmail != "" { 197 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 + } 199 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 200 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 202 201 - cmd = exec.Command("git", commitArgs...) 202 - } else { 203 - // If no commit message specified, use git-am which automatically creates a commit 204 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 205 } 206 + 207 + cmd = exec.Command("git", commitArgs...) 206 208 } 207 209 208 210 cmd.Stderr = &stderr 209 211 210 212 if err := cmd.Run(); err != nil { 211 - if checkOnly { 212 - conflicts := parseGitApplyErrors(stderr.String()) 213 - return &ErrMerge{ 214 - Message: "patch cannot be applied cleanly", 215 - Conflicts: conflicts, 216 - HasConflict: len(conflicts) > 0, 217 - OtherError: err, 218 - } 219 - } 220 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 214 } 222 215 ··· 227 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 221 return val 229 222 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 223 234 224 patchFile, err := g.createTempFileWithPatch(patchData) 235 225 if err != nil { ··· 249 239 } 250 240 defer os.RemoveAll(tmpDir) 251 241 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 242 + result := g.checkPatch(tmpDir, patchFile) 253 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 244 return result 255 245 } 256 246 257 - func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 258 - return g.MergeWithOptions(patchData, targetBranch, nil) 259 - } 260 - 261 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error { 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 262 248 patchFile, err := g.createTempFileWithPatch(patchData) 263 249 if err != nil { 264 250 return &ErrMerge{ ··· 277 263 } 278 264 defer os.RemoveAll(tmpDir) 279 265 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 281 267 return err 282 268 } 283 269
+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 }
+9 -4
knotserver/git.go
··· 13 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 56 } 57 57 } 58 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 105 } 106 106 } 107 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 118 d.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 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)
-211
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - "net/http" 8 - "runtime/debug" 9 - 10 - "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 19 - ) 20 - 21 - type Handle struct { 22 - c *config.Config 23 - db *db.DB 24 - jc *jetstream.JetstreamClient 25 - e *rbac.Enforcer 26 - l *slog.Logger 27 - n *notifier.Notifier 28 - resolver *idresolver.Resolver 29 - 30 - // init is a channel that is closed when the knot has been initailized 31 - // i.e. when the first user (knot owner) has been added. 32 - init chan struct{} 33 - knotInitialized bool 34 - } 35 - 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() 38 - 39 - h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - resolver: idresolver.DefaultResolver(), 47 - init: make(chan struct{}), 48 - } 49 - 50 - err := e.AddKnot(rbac.ThisServer) 51 - if err != nil { 52 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 - } 54 - 55 - err = h.jc.StartJetstream(ctx, h.processMessages) 56 - if err != nil { 57 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 58 - } 59 - 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() 64 - if err != nil { 65 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 66 - } 67 - 68 - if len(dids) > 0 { 69 - h.knotInitialized = true 70 - close(h.init) 71 - for _, d := range dids { 72 - h.jc.AddDid(d) 73 - } 74 - } 75 - 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 - }) 86 - 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 - }) 92 - 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 98 - 99 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 100 - 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 - }) 106 - 107 - r.Route("/tree/{ref}", func(r chi.Router) { 108 - r.Get("/", h.RepoIndex) 109 - r.Get("/*", h.RepoTree) 110 - }) 111 - 112 - r.Route("/blob/{ref}", func(r chi.Router) { 113 - r.Get("/*", h.Blob) 114 - }) 115 - 116 - r.Route("/raw/{ref}", func(r chi.Router) { 117 - r.Get("/*", h.BlobRaw) 118 - }) 119 - 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 - }) 134 - 135 - // xrpc apis 136 - r.Mount("/xrpc", h.XrpcRouter()) 137 - 138 - // Create a new repository. 139 - r.Route("/repo", func(r chi.Router) { 140 - r.Use(h.VerifySignature) 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 - }) 149 - 150 - r.Route("/member", func(r chi.Router) { 151 - r.Use(h.VerifySignature) 152 - r.Put("/add", h.AddMember) 153 - }) 154 - 155 - // Socket that streams git oplogs 156 - r.Get("/events", h.Events) 157 - 158 - // Initialize the knot with an owner and public key. 159 - r.With(h.VerifySignature).Post("/init", h.Init) 160 - 161 - // Health check. Used for two-way verification with appview. 162 - r.With(h.VerifySignature).Get("/health", h.Health) 163 - 164 - // All public keys on the knot. 165 - r.Get("/keys", h.Keys) 166 - 167 - return r, nil 168 - } 169 - 170 - func (h *Handle) XrpcRouter() http.Handler { 171 - logger := tlog.New("knots") 172 - 173 - xrpc := &xrpc.Xrpc{ 174 - Config: h.c, 175 - Db: h.db, 176 - Ingester: h.jc, 177 - Enforcer: h.e, 178 - Logger: logger, 179 - Notifier: h.n, 180 - Resolver: h.resolver, 181 - } 182 - return xrpc.Router() 183 - } 184 - 185 - // version is set during build time. 186 - var version string 187 - 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) 193 - return 194 - } 195 - 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 - } 202 - } 203 - 204 - if modVer == "" { 205 - version = "unknown" 206 - } 207 - } 208 - 209 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 - fmt.Fprintf(w, "knotserver/%s", version) 211 - }
-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 - }
+81 -91
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 *Knot) 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 *Knot) 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 *Knot) 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) 81 101 82 - 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) 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 86 104 } 87 105 88 - 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) 92 - } 106 + l = l.With("target_repo", record.Target.Repo) 107 + l = l.With("target_branch", record.Target.Branch) 93 108 94 - allDids, err := h.db.GetAllDids() 95 - if err != nil { 96 - return err 109 + if record.Source == nil { 110 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 97 111 } 98 112 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) 113 + if record.Source.Repo != nil { 114 + return fmt.Errorf("ignoring pull record: fork based pull") 104 115 } 105 116 106 - repoAt, err := syntax.ParseATURI(record.TargetRepo) 117 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 107 118 if err != nil { 108 - return err 119 + return fmt.Errorf("failed to parse ATURI: %w", err) 109 120 } 110 121 111 122 // resolve this aturi to extract the repo record ··· 121 132 122 133 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 123 134 if err != nil { 124 - return err 135 + return fmt.Errorf("failed to resolver repo: %w", err) 125 136 } 126 137 127 138 repo := resp.Value.Val.(*tangled.Repo) 128 139 129 140 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) 141 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 133 142 } 134 143 135 144 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 136 145 if err != nil { 137 - return err 146 + return fmt.Errorf("failed to construct relative repo path: %w", err) 138 147 } 139 148 140 149 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 141 150 if err != nil { 142 - return err 151 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 143 152 } 144 153 145 154 gr, err := git.Open(repoPath, record.Source.Branch) 146 155 if err != nil { 147 - return err 156 + return fmt.Errorf("failed to open git repository: %w", err) 148 157 } 149 158 150 159 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 151 160 if err != nil { 152 - return err 161 + return fmt.Errorf("failed to open workflow directory: %w", err) 153 162 } 154 163 155 - var pipeline workflow.Pipeline 164 + var pipeline workflow.RawPipeline 156 165 for _, e := range workflowDir { 157 166 if !e.IsFile { 158 167 continue ··· 164 173 continue 165 174 } 166 175 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) 176 + pipeline = append(pipeline, workflow.RawWorkflow{ 177 + Name: e.Name, 178 + Contents: contents, 179 + }) 175 180 } 176 181 177 182 trigger := tangled.Pipeline_PullRequestTriggerData{ 178 183 Action: "create", 179 184 SourceBranch: record.Source.Branch, 180 185 SourceSha: record.Source.Sha, 181 - TargetBranch: record.TargetBranch, 186 + TargetBranch: record.Target.Branch, 182 187 } 183 188 184 189 compiler := workflow.Compiler{ ··· 193 198 }, 194 199 } 195 200 196 - cp := compiler.Compile(pipeline) 201 + cp := compiler.Compile(compiler.Parse(pipeline)) 197 202 eventJson, err := json.Marshal(cp) 198 203 if err != nil { 199 - return err 204 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 200 205 } 201 206 202 207 // do not run empty pipelines ··· 204 209 return nil 205 210 } 206 211 207 - event := db.Event{ 212 + ev := db.Event{ 208 213 Rkey: TID(), 209 214 Nsid: tangled.PipelineNSID, 210 215 EventJson: string(eventJson), 211 216 } 212 217 213 - return h.db.InsertEvent(event, h.n) 218 + return h.db.InsertEvent(ev, h.n) 214 219 } 215 220 216 221 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 223 + raw := json.RawMessage(event.Commit.Record) 224 + did := event.Did 225 + 226 + var record tangled.RepoCollaborator 227 + if err := json.Unmarshal(raw, &record); err != nil { 228 + return fmt.Errorf("failed to unmarshal record: %w", err) 229 + } 230 + 218 231 repoAt, err := syntax.ParseATURI(record.Repo) 219 232 if err != nil { 220 233 return err ··· 247 260 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 261 249 262 // 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) 263 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 264 + if err != nil { 265 + return fmt.Errorf("failed to check permissions: %w", err) 266 + } 267 + if !ok { 268 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 252 269 } 253 270 254 271 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 263 280 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 264 281 } 265 282 266 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 267 284 l := log.FromContext(ctx) 268 285 269 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 290 307 return fmt.Errorf("error reading response body: %w", err) 291 308 } 292 309 293 - for _, key := range strings.Split(string(plaintext), "\n") { 310 + for key := range strings.SplitSeq(string(plaintext), "\n") { 294 311 if key == "" { 295 312 continue 296 313 } ··· 306 323 return nil 307 324 } 308 325 309 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 310 - did := event.Did 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 311 327 if event.Kind != models.EventKindCommit { 312 328 return nil 313 329 } ··· 321 337 } 322 338 }() 323 339 324 - raw := json.RawMessage(event.Commit.Record) 325 - 326 340 switch event.Commit.Collection { 327 341 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 - 342 + err = h.processPublicKey(ctx, event) 336 343 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 - 344 + err = h.processKnotMember(ctx, event) 345 345 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 - 346 + err = h.processPull(ctx, event) 354 347 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 - } 348 + err = h.processCollaborator(ctx, event) 349 + } 362 350 351 + if err != nil { 352 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 363 353 } 364 354 365 - return err 355 + return nil 366 356 }
+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
··· 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 - }
+152
knotserver/router.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.sh/tangled.sh/core/idresolver" 11 + "tangled.sh/tangled.sh/core/jetstream" 12 + "tangled.sh/tangled.sh/core/knotserver/config" 13 + "tangled.sh/tangled.sh/core/knotserver/db" 14 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 15 + tlog "tangled.sh/tangled.sh/core/log" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + ) 20 + 21 + type Knot struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 + } 30 + 31 + 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) { 32 + r := chi.NewRouter() 33 + 34 + h := Knot{ 35 + c: c, 36 + db: db, 37 + e: e, 38 + l: l, 39 + jc: jc, 40 + n: n, 41 + resolver: idresolver.DefaultResolver(), 42 + } 43 + 44 + err := e.AddKnot(rbac.ThisServer) 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 47 + } 48 + 49 + // configure owner 50 + if err = h.configureOwner(); err != nil { 51 + return nil, err 52 + } 53 + h.l.Info("owner set", "did", h.c.Server.Owner) 54 + h.jc.AddDid(h.c.Server.Owner) 55 + 56 + // configure known-dids in jetstream consumer 57 + dids, err := h.db.GetAllDids() 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get all dids: %w", err) 60 + } 61 + for _, d := range dids { 62 + jc.AddDid(d) 63 + } 64 + 65 + err = h.jc.StartJetstream(ctx, h.processMessages) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 + } 69 + 70 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 + }) 73 + 74 + r.Route("/{did}", func(r chi.Router) { 75 + r.Route("/{name}", func(r chi.Router) { 76 + // routes for git operations 77 + r.Get("/info/refs", h.InfoRefs) 78 + r.Post("/git-upload-pack", h.UploadPack) 79 + r.Post("/git-receive-pack", h.ReceivePack) 80 + }) 81 + }) 82 + 83 + // xrpc apis 84 + r.Mount("/xrpc", h.XrpcRouter()) 85 + 86 + // Socket that streams git oplogs 87 + r.Get("/events", h.Events) 88 + 89 + return r, nil 90 + } 91 + 92 + func (h *Knot) XrpcRouter() http.Handler { 93 + logger := tlog.New("knots") 94 + 95 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 + 97 + xrpc := &xrpc.Xrpc{ 98 + Config: h.c, 99 + Db: h.db, 100 + Ingester: h.jc, 101 + Enforcer: h.e, 102 + Logger: logger, 103 + Notifier: h.n, 104 + Resolver: h.resolver, 105 + ServiceAuth: serviceAuth, 106 + } 107 + return xrpc.Router() 108 + } 109 + 110 + func (h *Knot) configureOwner() error { 111 + cfgOwner := h.c.Server.Owner 112 + 113 + rbacDomain := "thisserver" 114 + 115 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 116 + if err != nil { 117 + return err 118 + } 119 + 120 + switch len(existing) { 121 + case 0: 122 + // no owner configured, continue 123 + case 1: 124 + // find existing owner 125 + existingOwner := existing[0] 126 + 127 + // no ownership change, this is okay 128 + if existingOwner == h.c.Server.Owner { 129 + break 130 + } 131 + 132 + // remove existing owner 133 + if err = h.db.RemoveDid(existingOwner); err != nil { 134 + return err 135 + } 136 + if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 137 + return err 138 + } 139 + 140 + default: 141 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 142 + } 143 + 144 + if err = h.db.AddDid(cfgOwner); err != nil { 145 + return fmt.Errorf("failed to add owner to DB: %w", err) 146 + } 147 + if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 148 + return fmt.Errorf("failed to add owner to RBAC: %w", err) 149 + } 150 + 151 + return nil 152 + }
-1348
knotserver/routes.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 - "fmt" 12 - "log" 13 - "net/http" 14 - "net/url" 15 - "os" 16 - "path/filepath" 17 - "strconv" 18 - "strings" 19 - "sync" 20 - "time" 21 - 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 - "github.com/go-chi/chi/v5" 25 - gogit "github.com/go-git/go-git/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 - "github.com/go-git/go-git/v5/plumbing/object" 28 - "tangled.sh/tangled.sh/core/hook" 29 - "tangled.sh/tangled.sh/core/knotserver/db" 30 - "tangled.sh/tangled.sh/core/knotserver/git" 31 - "tangled.sh/tangled.sh/core/patchutil" 32 - "tangled.sh/tangled.sh/core/rbac" 33 - "tangled.sh/tangled.sh/core/types" 34 - ) 35 - 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 - } 396 - } 397 - 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 - } 421 - 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 - } 426 - } 427 - 428 - // convert to offset/limit 429 - offset := (page - 1) * pageSize 430 - limit := pageSize 431 - 432 - commits, err := gr.Commits(offset, limit) 433 - 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, 449 - } 450 - 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 466 - } 467 - 468 - diff, err := gr.Diff() 469 - 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, 478 - } 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 492 - } 493 - 494 - tags, err := gr.Tags() 495 - if err != nil { 496 - // Non-fatal, we *should* have at least one branch to show. 497 - l.Warn("getting tags", "error", err.Error()) 498 - } 499 - 500 - rtags := []*types.TagReference{} 501 - for _, tag := range tags { 502 - var target *object.Tag 503 - if tag.Target != plumbing.ZeroHash { 504 - target = &tag 505 - } 506 - tr := types.TagReference{ 507 - Tag: target, 508 - } 509 - 510 - tr.Reference = types.Reference{ 511 - Name: tag.Name, 512 - Hash: tag.Hash.String(), 513 - } 514 - 515 - if tag.Message != "" { 516 - tr.Message = tag.Message 517 - } 518 - 519 - rtags = append(rtags, &tr) 520 - } 521 - 522 - resp := types.RepoTagsResponse{ 523 - Tags: rtags, 524 - } 525 - 526 - writeJSON(w, resp) 527 - return 528 - } 529 - 530 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 531 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 532 - 533 - gr, err := git.PlainOpen(path) 534 - if err != nil { 535 - notFound(w) 536 - return 537 - } 538 - 539 - branches, _ := gr.Branches() 540 - 541 - resp := types.RepoBranchesResponse{ 542 - Branches: branches, 543 - } 544 - 545 - writeJSON(w, resp) 546 - return 547 - } 548 - 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) 553 - 554 - l := h.l.With("handler", "Branch") 555 - 556 - gr, err := git.PlainOpen(path) 557 - if err != nil { 558 - notFound(w) 559 - return 560 - } 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 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()) 609 - return 610 - } 611 - 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) 624 - return 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) 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) 702 - } 703 - 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 - } 727 - 728 - var name string 729 - if data.Name != "" { 730 - name = data.Name 731 - } else { 732 - name = filepath.Base(source) 733 - } 734 - 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) 742 - 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 753 - } 754 - 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 - } 761 - 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 768 - } 769 - 770 - if isAncestor { 771 - status = types.FastForwardable 772 - } else { 773 - status = types.Conflict 774 - } 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()) 1004 - } 1005 - return 1006 - } 1007 - 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"` 1017 - } 1018 - 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 1023 - } 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 1031 - } 1032 - 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 - return nil 1348 - }
+16 -13
knotserver/server.go
··· 22 22 Usage: "run a knot server", 23 23 Action: Run, 24 24 Description: ` 25 - Environment variables: 26 - KNOT_SERVER_SECRET (required) 27 - KNOT_SERVER_HOSTNAME (required) 28 - KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 29 - KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 30 - KNOT_SERVER_DB_PATH (default: knotserver.db) 31 - KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 32 - KNOT_SERVER_DEV (default: false) 33 - KNOT_REPO_SCAN_PATH (default: /home/git) 34 - KNOT_REPO_README (comma-separated list) 35 - KNOT_REPO_MAIN_BRANCH (default: main) 36 - APPVIEW_ENDPOINT (default: https://tangled.sh) 37 - `, 25 + Environment variables: 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_HOSTNAME (required) 30 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 31 + KNOT_SERVER_OWNER (required) 32 + KNOT_SERVER_LOG_DIDS (default: true) 33 + KNOT_SERVER_DEV (default: false) 34 + KNOT_REPO_SCAN_PATH (default: /home/git) 35 + KNOT_REPO_README (comma-separated list) 36 + KNOT_REPO_MAIN_BRANCH (default: main) 37 + KNOT_GIT_USER_NAME (default: Tangled) 38 + KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh) 39 + APPVIEW_ENDPOINT (default: https://tangled.sh) 40 + `, 38 41 } 39 42 } 40 43
+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
··· 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
··· 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
··· 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 + }
+104
knotserver/xrpc/hidden_ref.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 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.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+58
knotserver/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 13 + cursor := r.URL.Query().Get("cursor") 14 + 15 + limit := 100 // default 16 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 17 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 18 + limit = l 19 + } 20 + } 21 + 22 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 23 + if err != nil { 24 + x.Logger.Error("failed to get public keys", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to retrieve public keys"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 33 + for _, key := range keys { 34 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 35 + Did: key.Did, 36 + Key: key.Key, 37 + CreatedAt: key.CreatedAt, 38 + }) 39 + } 40 + 41 + response := tangled.KnotListKeys_Output{ 42 + Keys: publicKeys, 43 + } 44 + 45 + if nextCursor != "" { 46 + response.Cursor = &nextCursor 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + if err := json.NewEncoder(w).Encode(response); err != nil { 51 + x.Logger.Error("failed to encode response", "error", err) 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("InternalServerError"), 54 + xrpcerr.WithMessage("failed to encode response"), 55 + ), http.StatusInternalServerError) 56 + return 57 + } 58 + }
+114
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.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 86 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 + 88 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 + if err != nil { 90 + var mergeErr *git.ErrMerge 91 + if errors.As(err, &mergeErr) { 92 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 93 + for i, conflict := range mergeErr.Conflicts { 94 + conflicts[i] = types.ConflictInfo{ 95 + Filename: conflict.Filename, 96 + Reason: conflict.Reason, 97 + } 98 + } 99 + 100 + conflictErr := xrpcerr.NewXrpcError( 101 + xrpcerr.WithTag("MergeConflict"), 102 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 103 + ) 104 + writeError(w, conflictErr, http.StatusConflict) 105 + return 106 + } else { 107 + l.Error("failed to merge", "error", err.Error()) 108 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 109 + return 110 + } 111 + } 112 + 113 + w.WriteHeader(http.StatusOK) 114 + }
+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 + }
+31
knotserver/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+80
knotserver/xrpc/repo_archive.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo, repoPath, unescapedRef, err := x.parseStandardParams(r) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + format := r.URL.Query().Get("format") 23 + if format == "" { 24 + format = "tar.gz" // default 25 + } 26 + 27 + prefix := r.URL.Query().Get("prefix") 28 + 29 + if format != "tar.gz" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("only tar.gz format is supported"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + gr, err := git.Open(repoPath, unescapedRef) 38 + if err != nil { 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("RefNotFound"), 41 + xrpcerr.WithMessage("repository or ref not found"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + repoParts := strings.Split(repo, "/") 47 + repoName := repoParts[len(repoParts)-1] 48 + 49 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 50 + 51 + var archivePrefix string 52 + if prefix != "" { 53 + archivePrefix = prefix 54 + } else { 55 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 56 + } 57 + 58 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 59 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 60 + w.Header().Set("Content-Type", "application/gzip") 61 + 62 + gw := gzip.NewWriter(w) 63 + defer gw.Close() 64 + 65 + err = gr.WriteTar(gw, archivePrefix) 66 + if err != nil { 67 + // once we start writing to the body we can't report error anymore 68 + // so we are only left with logging the error 69 + x.Logger.Error("writing tar file", "error", err.Error()) 70 + return 71 + } 72 + 73 + err = gw.Flush() 74 + if err != nil { 75 + // once we start writing to the body we can't report error anymore 76 + // so we are only left with logging the error 77 + x.Logger.Error("flushing", "error", err.Error()) 78 + return 79 + } 80 + }
+150
knotserver/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/knotserver/git" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 19 + _, repoPath, ref, err := x.parseStandardParams(r) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + treePath := r.URL.Query().Get("path") 26 + if treePath == "" { 27 + writeError(w, xrpcerr.NewXrpcError( 28 + xrpcerr.WithTag("InvalidRequest"), 29 + xrpcerr.WithMessage("missing path parameter"), 30 + ), http.StatusBadRequest) 31 + return 32 + } 33 + 34 + raw := r.URL.Query().Get("raw") == "true" 35 + 36 + gr, err := git.Open(repoPath, ref) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("RefNotFound"), 40 + xrpcerr.WithMessage("repository or ref not found"), 41 + ), http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + 73 + case strings.HasPrefix(mimeType, "text/"): 74 + w.Header().Set("Cache-Control", "public, no-cache") 75 + // serve all text content as text/plain 76 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 77 + 78 + case isTextualMimeType(mimeType): 79 + // handle textual application types (json, xml, etc.) as text/plain 80 + w.Header().Set("Cache-Control", "public, no-cache") 81 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 82 + 83 + default: 84 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 85 + writeError(w, xrpcerr.NewXrpcError( 86 + xrpcerr.WithTag("InvalidRequest"), 87 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 88 + ), http.StatusForbidden) 89 + return 90 + } 91 + w.Write(contents) 92 + return 93 + } 94 + 95 + isTextual := func(mt string) bool { 96 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 97 + } 98 + 99 + var content string 100 + var encoding string 101 + 102 + isBinary := !isTextual(mimeType) 103 + 104 + if isBinary { 105 + content = base64.StdEncoding.EncodeToString(contents) 106 + encoding = "base64" 107 + } else { 108 + content = string(contents) 109 + encoding = "utf-8" 110 + } 111 + 112 + response := tangled.RepoBlob_Output{ 113 + Ref: ref, 114 + Path: treePath, 115 + Content: content, 116 + Encoding: &encoding, 117 + Size: &[]int64{int64(len(contents))}[0], 118 + IsBinary: &isBinary, 119 + } 120 + 121 + if mimeType != "" { 122 + response.MimeType = &mimeType 123 + } 124 + 125 + w.Header().Set("Content-Type", "application/json") 126 + if err := json.NewEncoder(w).Encode(response); err != nil { 127 + x.Logger.Error("failed to encode response", "error", err) 128 + writeError(w, xrpcerr.NewXrpcError( 129 + xrpcerr.WithTag("InternalServerError"), 130 + xrpcerr.WithMessage("failed to encode response"), 131 + ), http.StatusInternalServerError) 132 + return 133 + } 134 + } 135 + 136 + // isTextualMimeType returns true if the MIME type represents textual content 137 + // that should be served as text/plain for security reasons 138 + func isTextualMimeType(mimeType string) bool { 139 + textualTypes := []string{ 140 + "application/json", 141 + "application/xml", 142 + "application/yaml", 143 + "application/x-yaml", 144 + "application/toml", 145 + "application/javascript", 146 + "application/ecmascript", 147 + } 148 + 149 + return slices.Contains(textualTypes, mimeType) 150 + }
+96
knotserver/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RepoNotFound"), 36 + xrpcerr.WithMessage("repository not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + ref, err := gr.Branch(branchName) 42 + if err != nil { 43 + x.Logger.Error("getting branch", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("BranchNotFound"), 46 + xrpcerr.WithMessage("branch not found"), 47 + ), http.StatusNotFound) 48 + return 49 + } 50 + 51 + commit, err := gr.Commit(ref.Hash()) 52 + if err != nil { 53 + x.Logger.Error("getting commit object", "error", err.Error()) 54 + writeError(w, xrpcerr.NewXrpcError( 55 + xrpcerr.WithTag("BranchNotFound"), 56 + xrpcerr.WithMessage("failed to get commit object"), 57 + ), http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + defaultBranch, err := gr.FindMainBranch() 62 + isDefault := false 63 + if err != nil { 64 + x.Logger.Error("getting default branch", "error", err.Error()) 65 + } else if defaultBranch == branchName { 66 + isDefault = true 67 + } 68 + 69 + response := tangled.RepoBranch_Output{ 70 + Name: ref.Name().Short(), 71 + Hash: ref.Hash().String(), 72 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 73 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 74 + IsDefault: &isDefault, 75 + } 76 + 77 + if commit.Message != "" { 78 + response.Message = &commit.Message 79 + } 80 + 81 + response.Author = &tangled.RepoBranch_Signature{ 82 + Name: commit.Author.Name, 83 + Email: commit.Author.Email, 84 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + if err := json.NewEncoder(w).Encode(response); err != nil { 89 + x.Logger.Error("failed to encode response", "error", err) 90 + writeError(w, xrpcerr.NewXrpcError( 91 + xrpcerr.WithTag("InternalServerError"), 92 + xrpcerr.WithMessage("failed to encode response"), 93 + ), http.StatusInternalServerError) 94 + return 95 + } 96 + }
+70
knotserver/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + cursor := r.URL.Query().Get("cursor") 22 + 23 + limit := 50 // default 24 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 25 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 26 + limit = l 27 + } 28 + } 29 + 30 + gr, err := git.PlainOpen(repoPath) 31 + if err != nil { 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("RepoNotFound"), 34 + xrpcerr.WithMessage("repository not found"), 35 + ), http.StatusNotFound) 36 + return 37 + } 38 + 39 + branches, _ := gr.Branches() 40 + 41 + offset := 0 42 + if cursor != "" { 43 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 44 + offset = o 45 + } 46 + } 47 + 48 + end := offset + limit 49 + if end > len(branches) { 50 + end = len(branches) 51 + } 52 + 53 + paginatedBranches := branches[offset:end] 54 + 55 + // Create response using existing types.RepoBranchesResponse 56 + response := types.RepoBranchesResponse{ 57 + Branches: paginatedBranches, 58 + } 59 + 60 + // Write JSON response directly 61 + w.Header().Set("Content-Type", "application/json") 62 + if err := json.NewEncoder(w).Encode(response); err != nil { 63 + x.Logger.Error("failed to encode response", "error", err) 64 + writeError(w, xrpcerr.NewXrpcError( 65 + xrpcerr.WithTag("InternalServerError"), 66 + xrpcerr.WithMessage("failed to encode response"), 67 + ), http.StatusInternalServerError) 68 + return 69 + } 70 + }
+98
knotserver/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + rev1Param := r.URL.Query().Get("rev1") 23 + if rev1Param == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing rev1 parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + rev2Param := r.URL.Query().Get("rev2") 32 + if rev2Param == "" { 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("InvalidRequest"), 35 + xrpcerr.WithMessage("missing rev2 parameter"), 36 + ), http.StatusBadRequest) 37 + return 38 + } 39 + 40 + rev1, _ := url.PathUnescape(rev1Param) 41 + rev2, _ := url.PathUnescape(rev2Param) 42 + 43 + gr, err := git.PlainOpen(repoPath) 44 + if err != nil { 45 + writeError(w, xrpcerr.NewXrpcError( 46 + xrpcerr.WithTag("RepoNotFound"), 47 + xrpcerr.WithMessage("repository not found"), 48 + ), http.StatusNotFound) 49 + return 50 + } 51 + 52 + commit1, err := gr.ResolveRevision(rev1) 53 + if err != nil { 54 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 55 + writeError(w, xrpcerr.NewXrpcError( 56 + xrpcerr.WithTag("RevisionNotFound"), 57 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 58 + ), http.StatusBadRequest) 59 + return 60 + } 61 + 62 + commit2, err := gr.ResolveRevision(rev2) 63 + if err != nil { 64 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 65 + writeError(w, xrpcerr.NewXrpcError( 66 + xrpcerr.WithTag("RevisionNotFound"), 67 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 68 + ), http.StatusBadRequest) 69 + return 70 + } 71 + 72 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 73 + if err != nil { 74 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 75 + writeError(w, xrpcerr.NewXrpcError( 76 + xrpcerr.WithTag("CompareError"), 77 + xrpcerr.WithMessage("error comparing revisions"), 78 + ), http.StatusBadRequest) 79 + return 80 + } 81 + 82 + resp := types.RepoFormatPatchResponse{ 83 + Rev1: commit1.Hash.String(), 84 + Rev2: commit2.Hash.String(), 85 + FormatPatch: formatPatch, 86 + Patch: rawPatch, 87 + } 88 + 89 + w.Header().Set("Content-Type", "application/json") 90 + if err := json.NewEncoder(w).Encode(resp); err != nil { 91 + x.Logger.Error("failed to encode response", "error", err) 92 + writeError(w, xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InternalServerError"), 94 + xrpcerr.WithMessage("failed to encode response"), 95 + ), http.StatusInternalServerError) 96 + return 97 + } 98 + }
+65
knotserver/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + refParam := r.URL.Query().Get("ref") 22 + if refParam == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing ref parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + ref, _ := url.QueryUnescape(refParam) 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RefNotFound"), 36 + xrpcerr.WithMessage("repository or ref not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + diff, err := gr.Diff() 42 + if err != nil { 43 + x.Logger.Error("getting diff", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("RefNotFound"), 46 + xrpcerr.WithMessage("failed to generate diff"), 47 + ), http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + resp := types.RepoCommitResponse{ 52 + Ref: ref, 53 + Diff: diff, 54 + } 55 + 56 + w.Header().Set("Content-Type", "application/json") 57 + if err := json.NewEncoder(w).Encode(resp); err != nil { 58 + x.Logger.Error("failed to encode response", "error", err) 59 + writeError(w, xrpcerr.NewXrpcError( 60 + xrpcerr.WithTag("InternalServerError"), 61 + xrpcerr.WithMessage("failed to encode response"), 62 + ), http.StatusInternalServerError) 63 + return 64 + } 65 + }
+54
knotserver/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.Open(repoPath, "") 21 + if err != nil { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("RepoNotFound"), 24 + xrpcerr.WithMessage("repository not found"), 25 + ), http.StatusNotFound) 26 + return 27 + } 28 + 29 + branch, err := gr.FindMainBranch() 30 + if err != nil { 31 + x.Logger.Error("getting default branch", "error", err.Error()) 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("failed to get default branch"), 35 + ), http.StatusInternalServerError) 36 + return 37 + } 38 + 39 + response := tangled.RepoGetDefaultBranch_Output{ 40 + Name: branch, 41 + Hash: "", 42 + When: "1970-01-01T00:00:00.000Z", 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + if err := json.NewEncoder(w).Encode(response); err != nil { 47 + x.Logger.Error("failed to encode response", "error", err) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("InternalServerError"), 50 + xrpcerr.WithMessage("failed to encode response"), 51 + ), http.StatusInternalServerError) 52 + return 53 + } 54 + }
+93
knotserver/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "math" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 17 + refParam := r.URL.Query().Get("ref") 18 + if refParam == "" { 19 + refParam = "HEAD" // default 20 + } 21 + ref, _ := url.PathUnescape(refParam) 22 + 23 + repo := r.URL.Query().Get("repo") 24 + repoPath, err := x.parseRepoParam(repo) 25 + if err != nil { 26 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + gr, err := git.Open(repoPath, ref) 31 + if err != nil { 32 + x.Logger.Error("opening repo", "error", err.Error()) 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("RefNotFound"), 35 + xrpcerr.WithMessage("repository or ref not found"), 36 + ), http.StatusNotFound) 37 + return 38 + } 39 + 40 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 41 + defer cancel() 42 + 43 + sizes, err := gr.AnalyzeLanguages(ctx) 44 + if err != nil { 45 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 46 + writeError(w, xrpcerr.NewXrpcError( 47 + xrpcerr.WithTag("InvalidRequest"), 48 + xrpcerr.WithMessage("failed to analyze repository languages"), 49 + ), http.StatusNoContent) 50 + return 51 + } 52 + 53 + var apiLanguages []*tangled.RepoLanguages_Language 54 + var totalSize int64 55 + 56 + for _, size := range sizes { 57 + totalSize += size 58 + } 59 + 60 + for name, size := range sizes { 61 + percentagef64 := float64(size) / float64(totalSize) * 100 62 + percentage := math.Round(percentagef64) 63 + 64 + lang := &tangled.RepoLanguages_Language{ 65 + Name: name, 66 + Size: size, 67 + Percentage: int64(percentage), 68 + } 69 + 70 + apiLanguages = append(apiLanguages, lang) 71 + } 72 + 73 + response := tangled.RepoLanguages_Output{ 74 + Ref: ref, 75 + Languages: apiLanguages, 76 + } 77 + 78 + if totalSize > 0 { 79 + response.TotalSize = &totalSize 80 + totalFiles := int64(len(sizes)) 81 + response.TotalFiles = &totalFiles 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + if err := json.NewEncoder(w).Encode(response); err != nil { 86 + x.Logger.Error("failed to encode response", "error", err) 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("InternalServerError"), 89 + xrpcerr.WithMessage("failed to encode response"), 90 + ), http.StatusInternalServerError) 91 + return 92 + } 93 + }
+101
knotserver/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "strconv" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + refParam := r.URL.Query().Get("ref") 23 + if refParam == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing ref parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + path := r.URL.Query().Get("path") 32 + cursor := r.URL.Query().Get("cursor") 33 + 34 + limit := 50 // default 35 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 36 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 37 + limit = l 38 + } 39 + } 40 + 41 + ref, err := url.QueryUnescape(refParam) 42 + if err != nil { 43 + writeError(w, xrpcerr.NewXrpcError( 44 + xrpcerr.WithTag("InvalidRequest"), 45 + xrpcerr.WithMessage("invalid ref parameter"), 46 + ), http.StatusBadRequest) 47 + return 48 + } 49 + 50 + gr, err := git.Open(repoPath, ref) 51 + if err != nil { 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("RefNotFound"), 54 + xrpcerr.WithMessage("repository or ref not found"), 55 + ), http.StatusNotFound) 56 + return 57 + } 58 + 59 + offset := 0 60 + if cursor != "" { 61 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 62 + offset = o 63 + } 64 + } 65 + 66 + commits, err := gr.Commits(offset, limit) 67 + if err != nil { 68 + x.Logger.Error("fetching commits", "error", err.Error()) 69 + writeError(w, xrpcerr.NewXrpcError( 70 + xrpcerr.WithTag("PathNotFound"), 71 + xrpcerr.WithMessage("failed to read commit log"), 72 + ), http.StatusNotFound) 73 + return 74 + } 75 + 76 + // Create response using existing types.RepoLogResponse 77 + response := types.RepoLogResponse{ 78 + Commits: commits, 79 + Ref: ref, 80 + Page: (offset / limit) + 1, 81 + PerPage: limit, 82 + Total: len(commits), // This is not accurate for pagination, but matches existing behavior 83 + } 84 + 85 + if path != "" { 86 + response.Description = path 87 + } 88 + 89 + response.Log = true 90 + 91 + // Write JSON response directly 92 + w.Header().Set("Content-Type", "application/json") 93 + if err := json.NewEncoder(w).Encode(response); err != nil { 94 + x.Logger.Error("failed to encode response", "error", err) 95 + writeError(w, xrpcerr.NewXrpcError( 96 + xrpcerr.WithTag("InternalServerError"), 97 + xrpcerr.WithMessage("failed to encode response"), 98 + ), http.StatusInternalServerError) 99 + return 100 + } 101 + }
+99
knotserver/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + "tangled.sh/tangled.sh/core/types" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + cursor := r.URL.Query().Get("cursor") 25 + 26 + limit := 50 // default 27 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 28 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 29 + limit = l 30 + } 31 + } 32 + 33 + gr, err := git.Open(repoPath, "") 34 + if err != nil { 35 + x.Logger.Error("failed to open", "error", err) 36 + writeError(w, xrpcerr.NewXrpcError( 37 + xrpcerr.WithTag("RepoNotFound"), 38 + xrpcerr.WithMessage("repository not found"), 39 + ), http.StatusNotFound) 40 + return 41 + } 42 + 43 + tags, err := gr.Tags() 44 + if err != nil { 45 + x.Logger.Warn("getting tags", "error", err.Error()) 46 + tags = []object.Tag{} 47 + } 48 + 49 + rtags := []*types.TagReference{} 50 + for _, tag := range tags { 51 + var target *object.Tag 52 + if tag.Target != plumbing.ZeroHash { 53 + target = &tag 54 + } 55 + tr := types.TagReference{ 56 + Tag: target, 57 + } 58 + 59 + tr.Reference = types.Reference{ 60 + Name: tag.Name, 61 + Hash: tag.Hash.String(), 62 + } 63 + 64 + if tag.Message != "" { 65 + tr.Message = tag.Message 66 + } 67 + 68 + rtags = append(rtags, &tr) 69 + } 70 + 71 + // apply pagination manually 72 + offset := 0 73 + if cursor != "" { 74 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 75 + offset = o 76 + } 77 + } 78 + 79 + // calculate end index 80 + end := min(offset+limit, len(rtags)) 81 + 82 + paginatedTags := rtags[offset:end] 83 + 84 + // Create response using existing types.RepoTagsResponse 85 + response := types.RepoTagsResponse{ 86 + Tags: paginatedTags, 87 + } 88 + 89 + // Write JSON response directly 90 + w.Header().Set("Content-Type", "application/json") 91 + if err := json.NewEncoder(w).Encode(response); err != nil { 92 + x.Logger.Error("failed to encode response", "error", err) 93 + writeError(w, xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InternalServerError"), 95 + xrpcerr.WithMessage("failed to encode response"), 96 + ), http.StatusInternalServerError) 97 + return 98 + } 99 + }
+116
knotserver/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "path/filepath" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 15 + ctx := r.Context() 16 + 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + refParam := r.URL.Query().Get("ref") 25 + if refParam == "" { 26 + writeError(w, xrpcerr.NewXrpcError( 27 + xrpcerr.WithTag("InvalidRequest"), 28 + xrpcerr.WithMessage("missing ref parameter"), 29 + ), http.StatusBadRequest) 30 + return 31 + } 32 + 33 + path := r.URL.Query().Get("path") 34 + // path can be empty (defaults to root) 35 + 36 + ref, err := url.QueryUnescape(refParam) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("InvalidRequest"), 40 + xrpcerr.WithMessage("invalid ref parameter"), 41 + ), http.StatusBadRequest) 42 + return 43 + } 44 + 45 + gr, err := git.Open(repoPath, ref) 46 + if err != nil { 47 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("RefNotFound"), 50 + xrpcerr.WithMessage("repository or ref not found"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + files, err := gr.FileTree(ctx, path) 56 + if err != nil { 57 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("PathNotFound"), 60 + xrpcerr.WithMessage("failed to read repository tree"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // convert NiceTree -> tangled.RepoTree_TreeEntry 66 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 67 + for i, file := range files { 68 + entry := &tangled.RepoTree_TreeEntry{ 69 + Name: file.Name, 70 + Mode: file.Mode, 71 + Size: file.Size, 72 + Is_file: file.IsFile, 73 + Is_subtree: file.IsSubtree, 74 + } 75 + 76 + if file.LastCommit != nil { 77 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 78 + Hash: file.LastCommit.Hash.String(), 79 + Message: file.LastCommit.Message, 80 + When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"), 81 + } 82 + } 83 + 84 + treeEntries[i] = entry 85 + } 86 + 87 + var parentPtr *string 88 + if path != "" { 89 + parentPtr = &path 90 + } 91 + 92 + var dotdotPtr *string 93 + if path != "" { 94 + dotdot := filepath.Dir(path) 95 + if dotdot != "." { 96 + dotdotPtr = &dotdot 97 + } 98 + } 99 + 100 + response := tangled.RepoTree_Output{ 101 + Ref: ref, 102 + Parent: parentPtr, 103 + Dotdot: dotdotPtr, 104 + Files: treeEntries, 105 + } 106 + 107 + w.Header().Set("Content-Type", "application/json") 108 + if err := json.NewEncoder(w).Encode(response); err != nil { 109 + x.Logger.Error("failed to encode response", "error", err) 110 + writeError(w, xrpcerr.NewXrpcError( 111 + xrpcerr.WithTag("InternalServerError"), 112 + xrpcerr.WithMessage("failed to encode response"), 113 + ), http.StatusInternalServerError) 114 + return 115 + } 116 + }
-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 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
+70
knotserver/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "runtime/debug" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + // version is set during build time. 14 + var version string 15 + 16 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 17 + if version == "" { 18 + info, ok := debug.ReadBuildInfo() 19 + if !ok { 20 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 21 + return 22 + } 23 + 24 + var modVer string 25 + var sha string 26 + var modified bool 27 + 28 + for _, mod := range info.Deps { 29 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 30 + modVer = mod.Version 31 + break 32 + } 33 + } 34 + 35 + for _, setting := range info.Settings { 36 + switch setting.Key { 37 + case "vcs.revision": 38 + sha = setting.Value 39 + case "vcs.modified": 40 + modified = setting.Value == "true" 41 + } 42 + } 43 + 44 + if modVer == "" { 45 + modVer = "unknown" 46 + } 47 + 48 + if sha == "" { 49 + version = modVer 50 + } else if modified { 51 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 52 + } else { 53 + version = fmt.Sprintf("%s (%s)", modVer, sha) 54 + } 55 + } 56 + 57 + response := tangled.KnotVersion_Output{ 58 + Version: version, 59 + } 60 + 61 + w.Header().Set("Content-Type", "application/json") 62 + if err := json.NewEncoder(w).Encode(response); err != nil { 63 + x.Logger.Error("failed to encode response", "error", err) 64 + writeError(w, xrpcerr.NewXrpcError( 65 + xrpcerr.WithTag("InternalServerError"), 66 + xrpcerr.WithMessage("failed to encode response"), 67 + ), http.StatusInternalServerError) 68 + return 69 + } 70 + }
+148
knotserver/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + 10 + securejoin "github.com/cyphar/filepath-securejoin" 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 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 + 21 + "github.com/go-chi/chi/v5" 22 + ) 23 + 24 + type Xrpc struct { 25 + Config *config.Config 26 + Db *db.DB 27 + Ingester *jetstream.JetstreamClient 28 + Enforcer *rbac.Enforcer 29 + Logger *slog.Logger 30 + Notifier *notifier.Notifier 31 + Resolver *idresolver.Resolver 32 + ServiceAuth *serviceauth.ServiceAuth 33 + } 34 + 35 + func (x *Xrpc) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 40 + 41 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 42 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 43 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 44 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 45 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 46 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 47 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 48 + }) 49 + 50 + // merge check is an open endpoint 51 + // 52 + // TODO: should we constrain this more? 53 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 54 + // - use ETags on clients to keep requests to a minimum 55 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 56 + 57 + // repo query endpoints (no auth required) 58 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 65 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 66 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 67 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 68 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 69 + 70 + // knot query endpoints (no auth required) 71 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 72 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 73 + 74 + // service query endpoints (no auth required) 75 + r.Get("/"+tangled.OwnerNSID, x.Owner) 76 + 77 + return r 78 + } 79 + 80 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 81 + // the full repository path on disk 82 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 83 + if repo == "" { 84 + return "", xrpcerr.NewXrpcError( 85 + xrpcerr.WithTag("InvalidRequest"), 86 + xrpcerr.WithMessage("missing repo parameter"), 87 + ) 88 + } 89 + 90 + // Parse repo string (did/repoName format) 91 + parts := strings.Split(repo, "/") 92 + if len(parts) < 2 { 93 + return "", xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InvalidRequest"), 95 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 96 + ) 97 + } 98 + 99 + did := strings.Join(parts[:len(parts)-1], "/") 100 + repoName := parts[len(parts)-1] 101 + 102 + // Construct repository path using the same logic as didPath 103 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 104 + if err != nil { 105 + return "", xrpcerr.NewXrpcError( 106 + xrpcerr.WithTag("RepoNotFound"), 107 + xrpcerr.WithMessage("failed to access repository"), 108 + ) 109 + } 110 + 111 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 112 + if err != nil { 113 + return "", xrpcerr.NewXrpcError( 114 + xrpcerr.WithTag("RepoNotFound"), 115 + xrpcerr.WithMessage("failed to access repository"), 116 + ) 117 + } 118 + 119 + return repoPath, nil 120 + } 121 + 122 + // parseStandardParams parses common query parameters used by most handlers 123 + func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) { 124 + // Parse repo parameter 125 + repo = r.URL.Query().Get("repo") 126 + repoPath, err = x.parseRepoParam(repo) 127 + if err != nil { 128 + return "", "", "", err 129 + } 130 + 131 + // Parse and unescape ref parameter 132 + refParam := r.URL.Query().Get("ref") 133 + if refParam == "" { 134 + return "", "", "", xrpcerr.NewXrpcError( 135 + xrpcerr.WithTag("InvalidRequest"), 136 + xrpcerr.WithMessage("missing ref parameter"), 137 + ) 138 + } 139 + 140 + ref, _ = url.QueryUnescape(refParam) 141 + return repo, repoPath, ref, nil 142 + } 143 + 144 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 145 + w.Header().Set("Content-Type", "application/json") 146 + w.WriteHeader(status) 147 + json.NewEncoder(w).Encode(e) 148 + }
+158
legal/privacy.md
··· 1 + # Privacy Policy 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + This Privacy Policy describes how Tangled ("we," "us," or "our") 6 + collects, uses, and shares your personal information when you use our 7 + platform and services (the "Service"). 8 + 9 + ## 1. Information We Collect 10 + 11 + ### Account Information 12 + 13 + When you create an account, we collect: 14 + 15 + - Your chosen username 16 + - Email address 17 + - Profile information you choose to provide 18 + - Authentication data 19 + 20 + ### Content and Activity 21 + 22 + We store: 23 + 24 + - Code repositories and associated metadata 25 + - Issues, pull requests, and comments 26 + - Activity logs and usage patterns 27 + - Public keys for authentication 28 + 29 + ## 2. Data Location and Hosting 30 + 31 + ### EU Data Hosting 32 + 33 + **All Tangled service data is hosted within the European Union.** 34 + Specifically: 35 + 36 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 + (*.tngl.sh) are located in Finland 38 + - **Application Data:** All other service data is stored on EU-based 39 + servers 40 + - **Data Processing:** All data processing occurs within EU 41 + jurisdiction 42 + 43 + ### External PDS Notice 44 + 45 + **Important:** If your account is hosted on Bluesky's PDS or other 46 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 + that data. The data protection, storage location, and privacy 48 + practices for such accounts are governed by the respective PDS 49 + provider's policies, not this Privacy Policy. We only control data 50 + processing within our own services and infrastructure. 51 + 52 + ## 3. Third-Party Data Processors 53 + 54 + We only share your data with the following third-party processors: 55 + 56 + ### Resend (Email Services) 57 + 58 + - **Purpose:** Sending transactional emails (account verification, 59 + notifications) 60 + - **Data Shared:** Email address and necessary message content 61 + 62 + ### Cloudflare (Image Caching) 63 + 64 + - **Purpose:** Caching and optimizing image delivery 65 + - **Data Shared:** Public images and associated metadata for caching 66 + purposes 67 + 68 + ### Posthog (Usage Metrics Tracking) 69 + 70 + - **Purpose:** Tracking usage and platform metrics 71 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 + information 73 + 74 + ## 4. How We Use Your Information 75 + 76 + We use your information to: 77 + 78 + - Provide and maintain the Service 79 + - Process your transactions and requests 80 + - Send you technical notices and support messages 81 + - Improve and develop new features 82 + - Ensure security and prevent fraud 83 + - Comply with legal obligations 84 + 85 + ## 5. Data Sharing and Disclosure 86 + 87 + We do not sell, trade, or rent your personal information. We may share 88 + your information only in the following circumstances: 89 + 90 + - With the third-party processors listed above 91 + - When required by law or legal process 92 + - To protect our rights, property, or safety, or that of our users 93 + - In connection with a merger, acquisition, or sale of assets (with 94 + appropriate protections) 95 + 96 + ## 6. Data Security 97 + 98 + We implement appropriate technical and organizational measures to 99 + protect your personal information against unauthorized access, 100 + alteration, disclosure, or destruction. However, no method of 101 + transmission over the Internet is 100% secure. 102 + 103 + ## 7. Data Retention 104 + 105 + We retain your personal information for as long as necessary to provide 106 + the Service and fulfill the purposes outlined in this Privacy Policy, 107 + unless a longer retention period is required by law. 108 + 109 + ## 8. Your Rights 110 + 111 + Under applicable data protection laws, you have the right to: 112 + 113 + - Access your personal information 114 + - Correct inaccurate information 115 + - Request deletion of your information 116 + - Object to processing of your information 117 + - Data portability 118 + - Withdraw consent (where applicable) 119 + 120 + ## 9. Cookies and Tracking 121 + 122 + We use cookies and similar technologies to: 123 + 124 + - Maintain your login session 125 + - Remember your preferences 126 + - Analyze usage patterns to improve the Service 127 + 128 + You can control cookie settings through your browser preferences. 129 + 130 + ## 10. Children's Privacy 131 + 132 + The Service is not intended for children under 16 years of age. We do 133 + not knowingly collect personal information from children under 16. If 134 + we become aware that we have collected such information, we will take 135 + steps to delete it. 136 + 137 + ## 11. International Data Transfers 138 + 139 + While all our primary data processing occurs within the EU, some of our 140 + third-party processors may process data outside the EU. When this 141 + occurs, we ensure appropriate safeguards are in place, such as Standard 142 + Contractual Clauses or adequacy decisions. 143 + 144 + ## 12. Changes to This Privacy Policy 145 + 146 + We may update this Privacy Policy from time to time. We will notify you 147 + of any changes by posting the new Privacy Policy on this page and 148 + updating the "Last updated" date. 149 + 150 + ## 13. Contact Information 151 + 152 + If you have any questions about this Privacy Policy or wish to exercise 153 + your rights, please contact us through our platform or via email. 154 + 155 + --- 156 + 157 + This Privacy Policy complies with the EU General Data Protection 158 + Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
··· 1 + # Terms of Service 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 + to and use of the Tangled platform and services (the "Service") 7 + operated by us ("Tangled," "we," "us," or "our"). 8 + 9 + ## 1. Acceptance of Terms 10 + 11 + By accessing or using our Service, you agree to be bound by these Terms. 12 + If you disagree with any part of these terms, then you may not access 13 + the Service. 14 + 15 + ## 2. Account Registration 16 + 17 + To use certain features of the Service, you must register for an 18 + account. You agree to provide accurate, current, and complete 19 + information during the registration process and to update such 20 + information to keep it accurate, current, and complete. 21 + 22 + ## 3. Account Termination 23 + 24 + > **Important Notice** 25 + > 26 + > **We reserve the right to terminate, suspend, or restrict access to 27 + > your account at any time, for any reason, or for no reason at all, at 28 + > our sole discretion.** This includes, but is not limited to, 29 + > termination for violation of these Terms, inappropriate conduct, spam, 30 + > abuse, or any other behavior we deem harmful to the Service or other 31 + > users. 32 + > 33 + > Account termination may result in the loss of access to your 34 + > repositories, data, and other content associated with your account. We 35 + > are not obligated to provide advance notice of termination, though we 36 + > may do so in our discretion. 37 + 38 + ## 4. Acceptable Use 39 + 40 + You agree not to use the Service to: 41 + 42 + - Violate any applicable laws or regulations 43 + - Infringe upon the rights of others 44 + - Upload, store, or share content that is illegal, harmful, threatening, 45 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 + objectionable 47 + - Engage in spam, phishing, or other deceptive practices 48 + - Attempt to gain unauthorized access to the Service or other users' 49 + accounts 50 + - Interfere with or disrupt the Service or servers connected to the 51 + Service 52 + 53 + ## 5. Content and Intellectual Property 54 + 55 + You retain ownership of the content you upload to the Service. By 56 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 + license to use, reproduce, modify, and distribute your content as 58 + necessary to provide the Service. 59 + 60 + ## 6. Privacy 61 + 62 + Your privacy is important to us. Please review our [Privacy 63 + Policy](/privacy), which also governs your use of the Service. 64 + 65 + ## 7. Disclaimers 66 + 67 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 + no warranties, expressed or implied, and hereby disclaim and negate all 69 + other warranties including without limitation, implied warranties or 70 + conditions of merchantability, fitness for a particular purpose, or 71 + non-infringement of intellectual property or other violation of rights. 72 + 73 + ## 8. Limitation of Liability 74 + 75 + In no event shall Tangled, nor its directors, employees, partners, 76 + agents, suppliers, or affiliates, be liable for any indirect, 77 + incidental, special, consequential, or punitive damages, including 78 + without limitation, loss of profits, data, use, goodwill, or other 79 + intangible losses, resulting from your use of the Service. 80 + 81 + ## 9. Indemnification 82 + 83 + You agree to defend, indemnify, and hold harmless Tangled and its 84 + affiliates, officers, directors, employees, and agents from and against 85 + any and all claims, damages, obligations, losses, liabilities, costs, 86 + or debt, and expenses (including attorney's fees). 87 + 88 + ## 10. Governing Law 89 + 90 + These Terms shall be interpreted and governed by the laws of Finland, 91 + without regard to its conflict of law provisions. 92 + 93 + ## 11. Changes to Terms 94 + 95 + We reserve the right to modify or replace these Terms at any time. If a 96 + revision is material, we will try to provide at least 30 days notice 97 + prior to any new terms taking effect. 98 + 99 + ## 12. Contact Information 100 + 101 + If you have any questions about these Terms of Service, please contact 102 + us through our platform or via email. 103 + 104 + --- 105 + 106 + These terms are effective as of the last updated date shown above and 107 + will remain in effect except with respect to any changes in their 108 + provisions in the future, which will be in effect immediately after 109 + being posted on this page.
+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 }
+4 -11
lexicons/issue/comment.json
··· 19 19 "type": "string", 20 20 "format": "at-uri" 21 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 22 "body": { 34 23 "type": "string" 35 24 }, 36 25 "createdAt": { 37 26 "type": "string", 38 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 39 32 } 40 33 } 41 34 }
+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
··· 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 + }
+73
lexicons/knot/listKeys.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.listKeys", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List all public keys stored in the knot server", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "Maximum number of keys to return", 14 + "minimum": 1, 15 + "maximum": 1000, 16 + "default": 100 17 + }, 18 + "cursor": { 19 + "type": "string", 20 + "description": "Pagination cursor" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["keys"], 29 + "properties": { 30 + "keys": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "#publicKey" 35 + } 36 + }, 37 + "cursor": { 38 + "type": "string", 39 + "description": "Pagination cursor for next page" 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InternalServerError", 47 + "description": "Failed to retrieve public keys" 48 + } 49 + ] 50 + }, 51 + "publicKey": { 52 + "type": "object", 53 + "required": ["did", "key", "createdAt"], 54 + "properties": { 55 + "did": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID associated with the public key" 59 + }, 60 + "key": { 61 + "type": "string", 62 + "maxLength": 4096, 63 + "description": "Public key contents" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "Key upload timestamp" 69 + } 70 + } 71 + } 72 + } 73 + }
+25
lexicons/knot/version.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.version", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the version of a knot", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "version" 14 + ], 15 + "properties": { 16 + "version": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "errors": [] 23 + } 24 + } 25 + }
+31
lexicons/owner.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.owner", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the owner of a service", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "owner" 14 + ], 15 + "properties": { 16 + "owner": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "OwnerNotFound", 26 + "description": "Owner is not set for this service" 27 + } 28 + ] 29 + } 30 + } 31 + }
+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
··· 19 19 "type": "string", 20 20 "format": "at-uri" 21 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 22 "body": { 34 23 "type": "string" 35 24 },
+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 },
+55
lexicons/repo/archive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.archive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "format": { 20 + "type": "string", 21 + "description": "Archive format", 22 + "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 23 + "default": "tar.gz" 24 + }, 25 + "prefix": { 26 + "type": "string", 27 + "description": "Prefix for files in the archive" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "*/*", 33 + "description": "Binary archive data" 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "RefNotFound", 42 + "description": "Git reference not found" 43 + }, 44 + { 45 + "name": "InvalidRequest", 46 + "description": "Invalid request parameters" 47 + }, 48 + { 49 + "name": "ArchiveError", 50 + "description": "Failed to create archive" 51 + } 52 + ] 53 + } 54 + } 55 + }
+138
lexicons/repo/blob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.blob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref", "path"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to the file within the repository" 22 + }, 23 + "raw": { 24 + "type": "boolean", 25 + "description": "Return raw file content instead of JSON response", 26 + "default": false 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["ref", "path", "content"], 35 + "properties": { 36 + "ref": { 37 + "type": "string", 38 + "description": "The git reference used" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "The file path" 43 + }, 44 + "content": { 45 + "type": "string", 46 + "description": "File content (base64 encoded for binary files)" 47 + }, 48 + "encoding": { 49 + "type": "string", 50 + "description": "Content encoding", 51 + "enum": ["utf-8", "base64"] 52 + }, 53 + "size": { 54 + "type": "integer", 55 + "description": "File size in bytes" 56 + }, 57 + "isBinary": { 58 + "type": "boolean", 59 + "description": "Whether the file is binary" 60 + }, 61 + "mimeType": { 62 + "type": "string", 63 + "description": "MIME type of the file" 64 + }, 65 + "lastCommit": { 66 + "type": "ref", 67 + "ref": "#lastCommit" 68 + } 69 + } 70 + } 71 + }, 72 + "errors": [ 73 + { 74 + "name": "RepoNotFound", 75 + "description": "Repository not found or access denied" 76 + }, 77 + { 78 + "name": "RefNotFound", 79 + "description": "Git reference not found" 80 + }, 81 + { 82 + "name": "FileNotFound", 83 + "description": "File not found at the specified path" 84 + }, 85 + { 86 + "name": "InvalidRequest", 87 + "description": "Invalid request parameters" 88 + } 89 + ] 90 + }, 91 + "lastCommit": { 92 + "type": "object", 93 + "required": ["hash", "message", "when"], 94 + "properties": { 95 + "hash": { 96 + "type": "string", 97 + "description": "Commit hash" 98 + }, 99 + "shortHash": { 100 + "type": "string", 101 + "description": "Short commit hash" 102 + }, 103 + "message": { 104 + "type": "string", 105 + "description": "Commit message" 106 + }, 107 + "author": { 108 + "type": "ref", 109 + "ref": "#signature" 110 + }, 111 + "when": { 112 + "type": "string", 113 + "format": "datetime", 114 + "description": "Commit timestamp" 115 + } 116 + } 117 + }, 118 + "signature": { 119 + "type": "object", 120 + "required": ["name", "email", "when"], 121 + "properties": { 122 + "name": { 123 + "type": "string", 124 + "description": "Author name" 125 + }, 126 + "email": { 127 + "type": "string", 128 + "description": "Author email" 129 + }, 130 + "when": { 131 + "type": "string", 132 + "format": "datetime", 133 + "description": "Author timestamp" 134 + } 135 + } 136 + } 137 + } 138 + }
+94
lexicons/repo/branch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "name"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "name": { 16 + "type": "string", 17 + "description": "Branch name to get information for" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["name", "hash", "when"], 26 + "properties": { 27 + "name": { 28 + "type": "string", 29 + "description": "Branch name" 30 + }, 31 + "hash": { 32 + "type": "string", 33 + "description": "Latest commit hash on this branch" 34 + }, 35 + "shortHash": { 36 + "type": "string", 37 + "description": "Short commit hash" 38 + }, 39 + "when": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Timestamp of latest commit" 43 + }, 44 + "message": { 45 + "type": "string", 46 + "description": "Latest commit message" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "#signature" 51 + }, 52 + "isDefault": { 53 + "type": "boolean", 54 + "description": "Whether this is the default branch" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "BranchNotFound", 66 + "description": "Branch not found" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "signature": { 75 + "type": "object", 76 + "required": ["name", "email", "when"], 77 + "properties": { 78 + "name": { 79 + "type": "string", 80 + "description": "Author name" 81 + }, 82 + "email": { 83 + "type": "string", 84 + "description": "Author email" 85 + }, 86 + "when": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "Author timestamp" 90 + } 91 + } 92 + } 93 + } 94 + }
+43
lexicons/repo/branches.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branches", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of branches to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+49
lexicons/repo/compare.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.compare", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "rev1", "rev2"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "rev1": { 16 + "type": "string", 17 + "description": "First revision (commit, branch, or tag)" 18 + }, 19 + "rev2": { 20 + "type": "string", 21 + "description": "Second revision (commit, branch, or tag)" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "*/*", 27 + "description": "Compare output in application/json" 28 + }, 29 + "errors": [ 30 + { 31 + "name": "RepoNotFound", 32 + "description": "Repository not found or access denied" 33 + }, 34 + { 35 + "name": "RevisionNotFound", 36 + "description": "One or both revisions not found" 37 + }, 38 + { 39 + "name": "InvalidRequest", 40 + "description": "Invalid request parameters" 41 + }, 42 + { 43 + "name": "CompareError", 44 + "description": "Failed to compare revisions" 45 + } 46 + ] 47 + } 48 + } 49 + }
+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
··· 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 + }
+40
lexicons/repo/diff.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.diff", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "*/*" 23 + }, 24 + "errors": [ 25 + { 26 + "name": "RepoNotFound", 27 + "description": "Repository not found or access denied" 28 + }, 29 + { 30 + "name": "RefNotFound", 31 + "description": "Git reference not found" 32 + }, 33 + { 34 + "name": "InvalidRequest", 35 + "description": "Invalid request parameters" 36 + } 37 + ] 38 + } 39 + } 40 + }
+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
··· 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 + }
+82
lexicons/repo/getDefaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["name", "hash", "when"], 22 + "properties": { 23 + "name": { 24 + "type": "string", 25 + "description": "Default branch name" 26 + }, 27 + "hash": { 28 + "type": "string", 29 + "description": "Latest commit hash on default branch" 30 + }, 31 + "shortHash": { 32 + "type": "string", 33 + "description": "Short commit hash" 34 + }, 35 + "when": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of latest commit" 39 + }, 40 + "message": { 41 + "type": "string", 42 + "description": "Latest commit message" 43 + }, 44 + "author": { 45 + "type": "ref", 46 + "ref": "#signature" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "InvalidRequest", 58 + "description": "Invalid request parameters" 59 + } 60 + ] 61 + }, 62 + "signature": { 63 + "type": "object", 64 + "required": ["name", "email", "when"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "description": "Author name" 69 + }, 70 + "email": { 71 + "type": "string", 72 + "description": "Author email" 73 + }, 74 + "when": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "Author timestamp" 78 + } 79 + } 80 + } 81 + } 82 + }
+59
lexicons/repo/hiddenRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+99
lexicons/repo/languages.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.languages", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)", 18 + "default": "HEAD" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["ref", "languages"], 27 + "properties": { 28 + "ref": { 29 + "type": "string", 30 + "description": "The git reference used" 31 + }, 32 + "languages": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#language" 37 + } 38 + }, 39 + "totalSize": { 40 + "type": "integer", 41 + "description": "Total size of all analyzed files in bytes" 42 + }, 43 + "totalFiles": { 44 + "type": "integer", 45 + "description": "Total number of files analyzed" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "RepoNotFound", 53 + "description": "Repository not found or access denied" 54 + }, 55 + { 56 + "name": "RefNotFound", 57 + "description": "Git reference not found" 58 + }, 59 + { 60 + "name": "InvalidRequest", 61 + "description": "Invalid request parameters" 62 + } 63 + ] 64 + }, 65 + "language": { 66 + "type": "object", 67 + "required": ["name", "size", "percentage"], 68 + "properties": { 69 + "name": { 70 + "type": "string", 71 + "description": "Programming language name" 72 + }, 73 + "size": { 74 + "type": "integer", 75 + "description": "Total size of files in this language (bytes)" 76 + }, 77 + "percentage": { 78 + "type": "integer", 79 + "description": "Percentage of total codebase (0-100)" 80 + }, 81 + "fileCount": { 82 + "type": "integer", 83 + "description": "Number of files in this language" 84 + }, 85 + "color": { 86 + "type": "string", 87 + "description": "Hex color code for this language" 88 + }, 89 + "extensions": { 90 + "type": "array", 91 + "items": { 92 + "type": "string" 93 + }, 94 + "description": "File extensions associated with this language" 95 + } 96 + } 97 + } 98 + } 99 + }
+60
lexicons/repo/log.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.log", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to filter commits by", 22 + "default": "" 23 + }, 24 + "limit": { 25 + "type": "integer", 26 + "description": "Maximum number of commits to return", 27 + "minimum": 1, 28 + "maximum": 100, 29 + "default": 50 30 + }, 31 + "cursor": { 32 + "type": "string", 33 + "description": "Pagination cursor (commit SHA)" 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "*/*" 39 + }, 40 + "errors": [ 41 + { 42 + "name": "RepoNotFound", 43 + "description": "Repository not found or access denied" 44 + }, 45 + { 46 + "name": "RefNotFound", 47 + "description": "Git reference not found" 48 + }, 49 + { 50 + "name": "PathNotFound", 51 + "description": "Path not found in repository" 52 + }, 53 + { 54 + "name": "InvalidRequest", 55 + "description": "Invalid request parameters" 56 + } 57 + ] 58 + } 59 + } 60 + }
+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
··· 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 + }
-1
lexicons/repo/repo.json
··· 34 34 }, 35 35 "description": { 36 36 "type": "string", 37 - "format": "datetime", 38 37 "minGraphemes": 1, 39 38 "maxGraphemes": 140 40 39 },
+43
lexicons/repo/tags.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of tags to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+123
lexicons/repo/tree.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tree", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path within the repository tree", 22 + "default": "" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["ref", "files"], 31 + "properties": { 32 + "ref": { 33 + "type": "string", 34 + "description": "The git reference used" 35 + }, 36 + "parent": { 37 + "type": "string", 38 + "description": "The parent path in the tree" 39 + }, 40 + "dotdot": { 41 + "type": "string", 42 + "description": "Parent directory path" 43 + }, 44 + "files": { 45 + "type": "array", 46 + "items": { 47 + "type": "ref", 48 + "ref": "#treeEntry" 49 + } 50 + } 51 + } 52 + } 53 + }, 54 + "errors": [ 55 + { 56 + "name": "RepoNotFound", 57 + "description": "Repository not found or access denied" 58 + }, 59 + { 60 + "name": "RefNotFound", 61 + "description": "Git reference not found" 62 + }, 63 + { 64 + "name": "PathNotFound", 65 + "description": "Path not found in repository tree" 66 + }, 67 + { 68 + "name": "InvalidRequest", 69 + "description": "Invalid request parameters" 70 + } 71 + ] 72 + }, 73 + "treeEntry": { 74 + "type": "object", 75 + "required": ["name", "mode", "size", "is_file", "is_subtree"], 76 + "properties": { 77 + "name": { 78 + "type": "string", 79 + "description": "Relative file or directory name" 80 + }, 81 + "mode": { 82 + "type": "string", 83 + "description": "File mode" 84 + }, 85 + "size": { 86 + "type": "integer", 87 + "description": "File size in bytes" 88 + }, 89 + "is_file": { 90 + "type": "boolean", 91 + "description": "Whether this entry is a file" 92 + }, 93 + "is_subtree": { 94 + "type": "boolean", 95 + "description": "Whether this entry is a directory/subtree" 96 + }, 97 + "last_commit": { 98 + "type": "ref", 99 + "ref": "#lastCommit" 100 + } 101 + } 102 + }, 103 + "lastCommit": { 104 + "type": "object", 105 + "required": ["hash", "message", "when"], 106 + "properties": { 107 + "hash": { 108 + "type": "string", 109 + "description": "Commit hash" 110 + }, 111 + "message": { 112 + "type": "string", 113 + "description": "Commit message" 114 + }, 115 + "when": { 116 + "type": "string", 117 + "format": "datetime", 118 + "description": "Commit timestamp" 119 + } 120 + } 121 + } 122 + } 123 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 }
+8 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: 7 + let 8 + version = "1.8.1-alpha"; 9 + in 7 10 buildGoApplication { 8 11 pname = "knot"; 9 - version = "0.1.0"; 12 + version = "1.8.1"; 10 13 inherit src modules; 11 14 12 15 doCheck = false; 13 16 14 17 subPackages = ["cmd/knot"]; 15 18 tags = ["libsqlite3"]; 19 + 20 + ldflags = [ 21 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 22 + ]; 16 23 17 24 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 25 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
+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 ];
+1 -1
patchutil/combinediff.go
··· 119 119 // we have f1 and f2, combine them 120 120 combined, err := combineFiles(f1, f2) 121 121 if err != nil { 122 - fmt.Println(err) 122 + // fmt.Println(err) 123 123 } 124 124 125 125 // combined can be nil commit 2 reverted all changes from commit 1
+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
··· 14 14 ) 15 15 16 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 18 assert.NoError(t, err) 19 19 20 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
-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
··· 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
··· 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
··· 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
··· 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
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 -18
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, ··· 200 203 w.Write(motd) 201 204 }) 202 205 mux.HandleFunc("/events", s.Events) 203 - mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 204 - w.Write([]byte(s.cfg.Server.Owner)) 205 - }) 206 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 207 207 208 208 mux.Mount("/xrpc", s.XrpcRouter()) ··· 212 212 func (s *Spindle) XrpcRouter() http.Handler { 213 213 logger := s.l.With("route", "xrpc") 214 214 215 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 + 215 217 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, 218 + Logger: logger, 219 + Db: s.db, 220 + Enforcer: s.e, 221 + Engines: s.engs, 222 + Config: s.cfg, 223 + Resolver: s.res, 224 + Vault: s.vault, 225 + ServiceAuth: serviceAuth, 223 226 } 224 227 225 228 return x.Router() ··· 242 245 return fmt.Errorf("no repo data found") 243 246 } 244 247 248 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 249 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 250 + } 251 + 245 252 // filter by repos 246 253 _, err = s.db.GetRepo( 247 254 tpl.TriggerMetadata.Repo.Knot, ··· 257 264 Rkey: msg.Rkey, 258 265 } 259 266 267 + workflows := make(map[models.Engine][]models.Workflow) 268 + 260 269 for _, w := range tpl.Workflows { 261 270 if w != nil { 262 - err := s.db.StatusPending(models.WorkflowId{ 271 + if _, ok := s.engs[w.Engine]; !ok { 272 + err = s.db.StatusFailed(models.WorkflowId{ 273 + PipelineId: pipelineId, 274 + Name: w.Name, 275 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 276 + if err != nil { 277 + return err 278 + } 279 + 280 + continue 281 + } 282 + 283 + eng := s.engs[w.Engine] 284 + 285 + if _, ok := workflows[eng]; !ok { 286 + workflows[eng] = []models.Workflow{} 287 + } 288 + 289 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 290 + if err != nil { 291 + return err 292 + } 293 + 294 + workflows[eng] = append(workflows[eng], *ewf) 295 + 296 + err = s.db.StatusPending(models.WorkflowId{ 263 297 PipelineId: pipelineId, 264 298 Name: w.Name, 265 299 }, s.n) ··· 269 303 } 270 304 } 271 305 272 - spl := models.ToPipeline(tpl, *s.cfg) 273 - 274 306 ok := s.jq.Enqueue(queue.Job{ 275 307 Run: func() error { 276 - s.eng.StartWorkflows(ctx, spl, pipelineId) 308 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 + RepoName: tpl.TriggerMetadata.Repo.Repo, 311 + Workflows: workflows, 312 + }, pipelineId) 277 313 return nil 278 314 }, 279 315 OnFail: func(jobError error) {
+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
··· 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
··· 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
+31
spindle/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+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
+20 -108
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) 42 - 43 - return r 44 - } 45 - 46 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 - l := x.Logger.With("url", r.URL) 49 - 50 - token := r.Header.Get("Authorization") 51 - token = strings.TrimPrefix(token, "Bearer ") 52 - 53 - s := auth.ServiceAuthValidator{ 54 - Audience: x.Config.Server.Did().String(), 55 - Dir: x.Resolver.Directory(), 56 - } 57 - 58 - did, err := s.Validate(r.Context(), token, nil) 59 - if err != nil { 60 - l.Error("signature verification failed", "err", err) 61 - writeError(w, AuthError(err), http.StatusForbidden) 62 - return 63 - } 64 - 65 - r = r.WithContext( 66 - context.WithValue(r.Context(), ActorDid, did), 67 - ) 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 68 40 69 - next.ServeHTTP(w, r) 41 + r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 + r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 + r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 70 44 }) 71 - } 72 - 73 - type XrpcError struct { 74 - Tag string `json:"error"` 75 - Message string `json:"message"` 76 - } 77 45 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 - } 46 + // service query endpoints (no auth required) 47 + r.Get("/"+tangled.OwnerNSID, x.Owner) 132 48 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 - ) 49 + return r 138 50 } 139 51 140 52 // this is slightly different from http_util::write_error to follow the spec: 141 53 // 142 54 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 55 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 144 56 w.Header().Set("Content-Type", "application/json") 145 57 w.WriteHeader(status) 146 58 json.NewEncoder(w).Encode(e)
+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
··· 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
··· 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
··· 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
··· 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 - }
+115
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 OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 59 + var AuthError = func(err error) XrpcError { 60 + return NewXrpcError( 61 + WithTag("Auth"), 62 + WithError(fmt.Errorf("signature verification failed: %w", err)), 63 + ) 64 + } 65 + 66 + var InvalidRepoError = func(r string) XrpcError { 67 + return NewXrpcError( 68 + WithTag("InvalidRepo"), 69 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 70 + ) 71 + } 72 + 73 + var GitError = func(e error) XrpcError { 74 + return NewXrpcError( 75 + WithTag("Git"), 76 + WithError(fmt.Errorf("git error: %w", e)), 77 + ) 78 + } 79 + 80 + var AccessControlError = func(d string) XrpcError { 81 + return NewXrpcError( 82 + WithTag("AccessControl"), 83 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 84 + ) 85 + } 86 + 87 + var RepoExistsError = func(r string) XrpcError { 88 + return NewXrpcError( 89 + WithTag("RepoExists"), 90 + WithError(fmt.Errorf("repo already exists: %s", r)), 91 + ) 92 + } 93 + 94 + var RecordExistsError = func(r string) XrpcError { 95 + return NewXrpcError( 96 + WithTag("RecordExists"), 97 + WithError(fmt.Errorf("repo already exists: %s", r)), 98 + ) 99 + } 100 + 101 + func GenericError(err error) XrpcError { 102 + return NewXrpcError( 103 + WithTag("Generic"), 104 + WithError(err), 105 + ) 106 + } 107 + 108 + func Unmarshal(errStr string) (XrpcError, error) { 109 + var xerr XrpcError 110 + err := json.Unmarshal([]byte(errStr), &xerr) 111 + if err != nil { 112 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 113 + } 114 + return xerr, nil 115 + }
+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 + }