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

Compare changes

Choose any two refs to compare.

+16184 -10455
+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 + }
+2
.tangled/workflows/build.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
+3 -12
.tangled/workflows/fmt.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 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
+2
.tangled/workflows/test.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
-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 GetFollowerFollowingCount(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 }
+459 -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)) 38 + } 39 + 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 + } 47 + } 48 + 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 31 54 } 32 55 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 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 43 59 } 44 60 45 - func NewIssue(tx *sql.Tx, issue *Issue) error { 46 - defer tx.Rollback() 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 47 65 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 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 + } 75 + } 76 + 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 82 + } 83 + 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 54 87 } 55 88 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 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 + }) 65 100 } 66 101 67 - issue.IssueId = nextId 102 + return listing 103 + } 68 104 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) 105 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 73 107 if err != nil { 74 - return err 108 + created = time.Now() 75 109 } 76 110 77 - lastID, err := res.LastInsertId() 78 - if err != nil { 79 - return err 111 + body := "" 112 + if record.Body != nil { 113 + body = *record.Body 80 114 } 81 - issue.ID = lastID 82 115 83 - if err := tx.Commit(); err != nil { 84 - return err 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 85 124 } 125 + } 86 126 87 - return nil 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 88 137 } 89 138 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 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 93 141 } 94 142 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 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 + } 99 150 } 100 151 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 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 105 154 } 106 155 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 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 + } 185 247 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) 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() 270 + 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 + } 334 + } 335 + 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 247 340 } 248 - repo.Created = repoCreatedTime 249 341 250 - issue.Metadata = &IssueMetadata{ 251 - Repo: &repo, 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 344 + } 345 + 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)) 350 + } 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, i := range issueMap { 363 + if r, ok := repoMap[string(i.RepoAt)]; ok { 364 + i.Repo = r 365 + } else { 366 + // do not show up the issue if the repo is deleted 367 + // TODO: foreign key where? 368 + delete(issueMap, issueAt) 252 369 } 370 + } 253 371 254 - issues = append(issues, issue) 372 + // collect comments 373 + issueAts := slices.Collect(maps.Keys(issueMap)) 374 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 + if err != nil { 376 + return nil, fmt.Errorf("failed to query comments: %w", err) 255 377 } 256 378 257 - if err := rows.Err(); err != nil { 258 - return nil, err 379 + for i := range comments { 380 + issueAt := comments[i].IssueAt 381 + if issue, ok := issueMap[issueAt]; ok { 382 + issue.Comments = append(issue.Comments, comments[i]) 383 + } 384 + } 385 + 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 259 389 } 260 390 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 394 + 261 395 return issues, nil 396 + } 397 + 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 262 400 } 263 401 264 402 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 = ?` 403 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 404 row := e.QueryRow(query, repoAt, issueId) 267 405 268 406 var issue Issue 269 407 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 409 if err != nil { 272 410 return nil, err 273 411 } ··· 281 419 return &issue, nil 282 420 } 283 421 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) 422 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 423 + result, err := e.Exec( 424 + `insert into issue_comments ( 425 + did, 426 + rkey, 427 + issue_at, 428 + body, 429 + reply_to, 430 + created, 431 + edited 432 + ) 433 + values (?, ?, ?, ?, ?, ?, null) 434 + on conflict(did, rkey) do update set 435 + issue_at = excluded.issue_at, 436 + body = excluded.body, 437 + edited = case 438 + when 439 + issue_comments.issue_at != excluded.issue_at 440 + or issue_comments.body != excluded.body 441 + or issue_comments.reply_to != excluded.reply_to 442 + then ? 443 + else issue_comments.edited 444 + end`, 445 + c.Did, 446 + c.Rkey, 447 + c.IssueAt, 448 + c.Body, 449 + c.ReplyTo, 450 + c.Created.Format(time.RFC3339), 451 + time.Now().Format(time.RFC3339), 452 + ) 291 453 if err != nil { 292 - return nil, nil, err 454 + return 0, err 293 455 } 294 456 295 - createdTime, err := time.Parse(time.RFC3339, createdAt) 457 + id, err := result.LastInsertId() 296 458 if err != nil { 297 - return nil, nil, err 459 + return 0, err 298 460 } 299 - issue.Created = createdTime 300 461 301 - comments, err := GetComments(e, repoAt, issueId) 302 - if err != nil { 303 - return nil, nil, err 462 + return id, nil 463 + } 464 + 465 + func DeleteIssueComments(e Execer, filters ...filter) error { 466 + var conditions []string 467 + var args []any 468 + for _, filter := range filters { 469 + conditions = append(conditions, filter.Condition()) 470 + args = append(args, filter.Arg()...) 304 471 } 305 472 306 - return &issue, comments, nil 307 - } 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 308 477 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 - ) 478 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 479 + 480 + _, err := e.Exec(query, args...) 320 481 return err 321 482 } 322 483 323 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 324 - var comments []Comment 484 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 + var comments []IssueComment 486 + 487 + var conditions []string 488 + var args []any 489 + for _, filter := range filters { 490 + conditions = append(conditions, filter.Condition()) 491 + args = append(args, filter.Arg()...) 492 + } 325 493 326 - rows, err := e.Query(` 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 498 + 499 + query := fmt.Sprintf(` 327 500 select 328 - owner_did, 329 - issue_id, 330 - comment_id, 501 + id, 502 + did, 331 503 rkey, 504 + issue_at, 505 + reply_to, 332 506 body, 333 507 created, 334 508 edited, 335 509 deleted 336 510 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 - } 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 348 516 if err != nil { 349 517 return nil, err 350 518 } 351 - defer rows.Close() 352 519 353 520 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) 521 + var comment IssueComment 522 + var created string 523 + var rkey, edited, deleted, replyTo sql.Null[string] 524 + err := rows.Scan( 525 + &comment.Id, 526 + &comment.Did, 527 + &rkey, 528 + &comment.IssueAt, 529 + &replyTo, 530 + &comment.Body, 531 + &created, 532 + &edited, 533 + &deleted, 534 + ) 358 535 if err != nil { 359 536 return nil, err 360 537 } 361 538 362 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 363 - if err != nil { 364 - return nil, err 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 365 542 } 366 - comment.Created = &createdAtTime 367 543 368 - if deletedAt.Valid { 369 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 370 - if err != nil { 371 - return nil, err 544 + if t, err := time.Parse(time.RFC3339, created); err == nil { 545 + comment.Created = t 546 + } 547 + 548 + if edited.Valid { 549 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 550 + comment.Edited = &t 372 551 } 373 - comment.Deleted = &deletedTime 374 552 } 375 553 376 - if editedAt.Valid { 377 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 378 - if err != nil { 379 - return nil, err 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 380 557 } 381 - comment.Edited = &editedTime 382 558 } 383 559 384 - if rkey.Valid { 385 - comment.Rkey = rkey.String 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 386 562 } 387 563 388 564 comments = append(comments, comment) 389 565 } 390 566 391 - if err := rows.Err(); err != nil { 567 + if err = rows.Err(); err != nil { 392 568 return nil, err 393 569 } 394 570 395 571 return comments, nil 396 572 } 397 573 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 574 + func DeleteIssues(e Execer, filters ...filter) error { 575 + var conditions []string 576 + var args []any 577 + for _, filter := range filters { 578 + conditions = append(conditions, filter.Condition()) 579 + args = append(args, filter.Arg()...) 413 580 } 414 581 415 - createdTime, err := time.Parse(time.RFC3339, createdAt) 416 - if err != nil { 417 - return nil, err 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 418 585 } 419 - comment.Created = &createdTime 420 586 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 - } 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 428 591 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 592 + func CloseIssues(e Execer, filters ...filter) error { 593 + var conditions []string 594 + var args []any 595 + for _, filter := range filters { 596 + conditions = append(conditions, filter.Condition()) 597 + args = append(args, filter.Arg()...) 435 598 } 436 599 437 - if rkey.Valid { 438 - comment.Rkey = rkey.String 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 439 603 } 440 604 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) 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 456 607 return err 457 608 } 458 609 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 - } 610 + func ReopenIssues(e Execer, filters ...filter) error { 611 + var conditions []string 612 + var args []any 613 + for _, filter := range filters { 614 + conditions = append(conditions, filter.Condition()) 615 + args = append(args, filter.Arg()...) 616 + } 469 617 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 - } 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 474 622 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) 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 477 625 return err 478 626 } 479 627
+25 -12
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) { ··· 540 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 541 554 args = append(args, did, PullOpen) 542 555 case VanityStatOpenIssueCount: 543 - query = `select count(id) from issues where owner_did = ? and open = 1` 556 + query = `select count(id) from issues where did = ? and open = 1` 544 557 args = append(args, did) 545 558 case VanityStatClosedIssueCount: 546 - query = `select count(id) from issues where owner_did = ? and open = 0` 559 + query = `select count(id) from issues where did = ? and open = 0` 547 560 args = append(args, did) 548 561 case VanityStatRepositoryCount: 549 562 query = `select count(id) from repos where did = ?` ··· 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)
+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 }
+35 -139
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 295 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 - 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 } ··· 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 := GetFollowerFollowingCount(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 + }
+478 -282
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) 73 - return 74 - } 75 - 76 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 77 - 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.") 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 80 82 return 81 83 } 82 84 83 - reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 85 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 84 86 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 - } 93 - 94 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 95 - if err != nil { 96 - log.Println("failed to resolve issue owner", err) 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 97 93 } 98 94 99 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 100 - LoggedInUser: user, 101 - RepoInfo: f.RepoInfo(user), 102 - Issue: *issue, 103 - Comments: comments, 104 - 105 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 106 - 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 107 100 OrderedReactionKinds: db.OrderedReactionKinds, 108 101 Reactions: reactionCountMap, 109 102 UserReacted: userReactions, 110 103 }) 111 - 112 104 } 113 105 114 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 115 108 user := rp.oauth.GetUser(r) 116 109 f, err := rp.repoResolver.Resolve(r) 117 110 if err != nil { ··· 119 112 return 120 113 } 121 114 122 - issueId := chi.URLParam(r, "issue") 123 - issueIdInt, err := strconv.Atoi(issueId) 124 - if err != nil { 125 - http.Error(w, "bad issue id", http.StatusBadRequest) 126 - log.Println("failed to parse issue id", err) 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 127 119 return 128 120 } 129 121 130 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 131 - if err != nil { 132 - log.Println("failed to get issue", err) 133 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 134 - return 135 - } 136 - 137 - collaborators, err := f.Collaborators(r.Context()) 138 - if err != nil { 139 - log.Println("failed to fetch repo collaborators: %w", err) 140 - } 141 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 142 - return user.Did == collab.Did 143 - }) 144 - isIssueOwner := user.Did == issue.OwnerDid 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") 145 134 146 - // TODO: make this more granular 147 - if isIssueOwner || isCollaborator { 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 139 + } 148 140 149 - closed := tangled.RepoIssueStateClosed 141 + newRecord := newIssue.AsRecord() 150 142 143 + // edit an atproto record 151 144 client, err := rp.oauth.AuthorizedClient(r) 152 145 if err != nil { 153 - log.Println("failed to get authorized client", err) 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 154 148 return 155 149 } 150 + 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 + 156 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 157 - Collection: tangled.RepoIssueStateNSID, 159 + Collection: tangled.RepoIssueNSID, 158 160 Repo: user.Did, 159 - Rkey: tid.TID(), 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 160 163 Record: &lexutil.LexiconTypeDecoder{ 161 - Val: &tangled.RepoIssueState{ 162 - Issue: issue.IssueAt, 163 - State: closed, 164 - }, 164 + Val: &newRecord, 165 165 }, 166 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 + } 167 172 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 168 175 if err != nil { 169 - log.Println("failed to update issue state", err) 170 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 171 178 return 172 179 } 180 + defer tx.Rollback() 173 181 174 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 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 + } 194 + 195 + rp.pages.HxRefresh(w) 196 + } 197 + } 198 + 199 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 + l := rp.logger.With("handler", "DeleteIssue") 201 + noticeId := "issue-actions-error" 202 + 203 + user := rp.oauth.GetUser(r) 204 + 205 + f, err := rp.repoResolver.Resolve(r) 206 + if err != nil { 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.") 215 + return 216 + } 217 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 218 + 219 + // delete from PDS 220 + client, err := rp.oauth.AuthorizedClient(r) 221 + if err != nil { 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.") 235 + return 236 + } 237 + 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) 253 + if err != nil { 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) 262 + return 263 + } 264 + 265 + collaborators, err := f.Collaborators(r.Context()) 266 + if err != nil { 267 + log.Println("failed to fetch repo collaborators: %w", err) 268 + } 269 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 270 + return user.Did == collab.Did 271 + }) 272 + isIssueOwner := user.Did == issue.Did 273 + 274 + // TODO: make this more granular 275 + if isIssueOwner || isCollaborator { 276 + err = db.CloseIssues( 277 + rp.db, 278 + db.FilterEq("id", issue.Id), 279 + ) 175 280 if err != nil { 176 281 log.Println("failed to close issue", err) 177 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 178 283 return 179 284 } 180 285 181 - 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)) 182 287 return 183 288 } else { 184 289 log.Println("user is not permitted to close issue") ··· 188 293 } 189 294 190 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 191 297 user := rp.oauth.GetUser(r) 192 298 f, err := rp.repoResolver.Resolve(r) 193 299 if err != nil { ··· 195 301 return 196 302 } 197 303 198 - issueId := chi.URLParam(r, "issue") 199 - issueIdInt, err := strconv.Atoi(issueId) 200 - if err != nil { 201 - http.Error(w, "bad issue id", http.StatusBadRequest) 202 - log.Println("failed to parse issue id", err) 203 - return 204 - } 205 - 206 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 207 - if err != nil { 208 - log.Println("failed to get issue", err) 209 - 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) 210 308 return 211 309 } 212 310 ··· 217 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 218 316 return user.Did == collab.Did 219 317 }) 220 - isIssueOwner := user.Did == issue.OwnerDid 318 + isIssueOwner := user.Did == issue.Did 221 319 222 320 if isCollaborator || isIssueOwner { 223 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 224 325 if err != nil { 225 326 log.Println("failed to reopen issue", err) 226 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 227 328 return 228 329 } 229 - 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)) 230 331 return 231 332 } else { 232 333 log.Println("user is not the owner of the repo") ··· 236 337 } 237 338 238 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 239 341 user := rp.oauth.GetUser(r) 240 342 f, err := rp.repoResolver.Resolve(r) 241 343 if err != nil { 242 - log.Println("failed to get repo and knot", err) 344 + l.Error("failed to get repo and knot", "err", err) 243 345 return 244 346 } 245 347 246 - issueId := chi.URLParam(r, "issue") 247 - issueIdInt, err := strconv.Atoi(issueId) 248 - if err != nil { 249 - http.Error(w, "bad issue id", http.StatusBadRequest) 250 - 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) 251 352 return 252 353 } 253 354 254 - switch r.Method { 255 - case http.MethodPost: 256 - body := r.FormValue("body") 257 - if body == "" { 258 - rp.pages.Notice(w, "issue", "Body is required") 259 - return 260 - } 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 261 360 262 - commentId := mathrand.IntN(1000000) 263 - rkey := tid.TID() 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 264 366 265 - err := db.NewIssueComment(rp.db, &db.Comment{ 266 - OwnerDid: user.Did, 267 - RepoAt: f.RepoAt, 268 - Issue: issueIdInt, 269 - CommentId: commentId, 270 - Body: body, 271 - Rkey: rkey, 272 - }) 273 - if err != nil { 274 - log.Println("failed to create comment", err) 275 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 276 - return 277 - } 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.") 378 + return 379 + } 380 + record := comment.AsRecord() 278 381 279 - createdAt := time.Now().Format(time.RFC3339) 280 - commentIdInt64 := int64(commentId) 281 - ownerDid := user.Did 282 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 283 - if err != nil { 284 - log.Println("failed to get issue at", err) 285 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 - return 287 - } 382 + client, err := rp.oauth.AuthorizedClient(r) 383 + if err != nil { 384 + l.Error("failed to get authorized client", "err", err) 385 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 386 + return 387 + } 288 388 289 - atUri := f.RepoAt.String() 290 - client, err := rp.oauth.AuthorizedClient(r) 291 - if err != nil { 292 - log.Println("failed to get authorized client", err) 293 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 294 - return 295 - } 296 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 297 - Collection: tangled.RepoIssueCommentNSID, 298 - Repo: user.Did, 299 - Rkey: rkey, 300 - Record: &lexutil.LexiconTypeDecoder{ 301 - Val: &tangled.RepoIssueComment{ 302 - Repo: &atUri, 303 - Issue: issueAt, 304 - CommentId: &commentIdInt64, 305 - Owner: &ownerDid, 306 - Body: body, 307 - CreatedAt: createdAt, 308 - }, 309 - }, 310 - }) 311 - if err != nil { 312 - log.Println("failed to create comment", err) 313 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 314 - return 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 + }) 398 + if err != nil { 399 + l.Error("failed to create comment", "err", err) 400 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 401 + return 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) 315 407 } 408 + }() 316 409 317 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 410 + commentId, err := db.AddIssueComment(rp.db, comment) 411 + if err != nil { 412 + l.Error("failed to create comment", "err", err) 413 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 318 414 return 319 415 } 416 + 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)) 320 420 } 321 421 322 422 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 323 424 user := rp.oauth.GetUser(r) 324 425 f, err := rp.repoResolver.Resolve(r) 325 426 if err != nil { 326 - log.Println("failed to get repo and knot", err) 427 + l.Error("failed to get repo and knot", "err", err) 327 428 return 328 429 } 329 430 330 - issueId := chi.URLParam(r, "issue") 331 - issueIdInt, err := strconv.Atoi(issueId) 332 - if err != nil { 333 - http.Error(w, "bad issue id", http.StatusBadRequest) 334 - log.Println("failed to parse issue id", err) 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 335 435 return 336 436 } 337 437 338 - commentId := chi.URLParam(r, "comment_id") 339 - commentIdInt, err := strconv.Atoi(commentId) 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 340 443 if err != nil { 341 - http.Error(w, "bad comment id", http.StatusBadRequest) 342 - log.Println("failed to parse issue id", err) 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 343 446 return 344 447 } 345 - 346 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 347 - if err != nil { 348 - log.Println("failed to get issue", err) 349 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 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) 350 451 return 351 452 } 453 + comment := comments[0] 352 454 353 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 354 - if err != nil { 355 - http.Error(w, "bad comment id", http.StatusBadRequest) 356 - return 357 - } 358 - 359 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 360 456 LoggedInUser: user, 361 457 RepoInfo: f.RepoInfo(user), 362 458 Issue: issue, 363 - Comment: comment, 459 + Comment: &comment, 364 460 }) 365 461 } 366 462 367 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 368 465 user := rp.oauth.GetUser(r) 369 466 f, err := rp.repoResolver.Resolve(r) 370 467 if err != nil { 371 - log.Println("failed to get repo and knot", err) 468 + l.Error("failed to get repo and knot", "err", err) 372 469 return 373 470 } 374 471 375 - issueId := chi.URLParam(r, "issue") 376 - issueIdInt, err := strconv.Atoi(issueId) 377 - if err != nil { 378 - http.Error(w, "bad issue id", http.StatusBadRequest) 379 - 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) 380 476 return 381 477 } 382 478 383 - commentId := chi.URLParam(r, "comment_id") 384 - commentIdInt, err := strconv.Atoi(commentId) 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 385 484 if err != nil { 386 - http.Error(w, "bad comment id", http.StatusBadRequest) 387 - log.Println("failed to parse issue id", err) 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 388 487 return 389 488 } 390 - 391 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 392 - if err != nil { 393 - log.Println("failed to get issue", err) 394 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 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) 395 492 return 396 493 } 494 + comment := comments[0] 397 495 398 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 399 - if err != nil { 400 - http.Error(w, "bad comment id", http.StatusBadRequest) 401 - return 402 - } 403 - 404 - if comment.OwnerDid != user.Did { 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 405 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 406 499 return 407 500 } ··· 412 505 LoggedInUser: user, 413 506 RepoInfo: f.RepoInfo(user), 414 507 Issue: issue, 415 - Comment: comment, 508 + Comment: &comment, 416 509 }) 417 510 case http.MethodPost: 418 511 // extract form value ··· 423 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 424 517 return 425 518 } 426 - rkey := comment.Rkey 427 519 428 - // optimistic update 429 - edited := time.Now() 430 - 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) 431 527 if err != nil { 432 528 log.Println("failed to perferom update-description query", err) 433 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 435 531 } 436 532 437 533 // rkey is optional, it was introduced later 438 - if comment.Rkey != "" { 534 + if newComment.Rkey != "" { 439 535 // update the record on pds 440 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 441 537 if err != nil { 442 - // failed to get record 443 - log.Println(err, rkey) 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 444 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 445 540 return 446 541 } 447 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 448 - record, _ := data.UnmarshalJSON(value) 449 - 450 - repoAt := record["repo"].(string) 451 - issueAt := record["issue"].(string) 452 - createdAt := record["createdAt"].(string) 453 - commentIdInt64 := int64(commentIdInt) 454 542 455 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 456 544 Collection: tangled.RepoIssueCommentNSID, 457 545 Repo: user.Did, 458 - Rkey: rkey, 546 + Rkey: newComment.Rkey, 459 547 SwapRecord: ex.Cid, 460 548 Record: &lexutil.LexiconTypeDecoder{ 461 - Val: &tangled.RepoIssueComment{ 462 - Repo: &repoAt, 463 - Issue: issueAt, 464 - CommentId: &commentIdInt64, 465 - Owner: &comment.OwnerDid, 466 - Body: newBody, 467 - CreatedAt: createdAt, 468 - }, 549 + Val: &record, 469 550 }, 470 551 }) 471 552 if err != nil { 472 - log.Println(err) 553 + l.Error("failed to update record on PDS", "err", err) 473 554 } 474 555 } 475 556 476 - // optimistic update for htmx 477 - comment.Body = newBody 478 - comment.Edited = &edited 479 - 480 557 // return new comment body with htmx 481 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 482 559 LoggedInUser: user, 483 560 RepoInfo: f.RepoInfo(user), 484 561 Issue: issue, 485 - Comment: comment, 562 + Comment: &newComment, 486 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) 487 573 return 574 + } 488 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 489 581 } 490 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 + }) 491 606 } 492 607 493 - 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") 494 610 user := rp.oauth.GetUser(r) 495 611 f, err := rp.repoResolver.Resolve(r) 496 612 if err != nil { 497 - log.Println("failed to get repo and knot", err) 613 + l.Error("failed to get repo and knot", "err", err) 498 614 return 499 615 } 500 616 501 - issueId := chi.URLParam(r, "issue") 502 - issueIdInt, err := strconv.Atoi(issueId) 503 - if err != nil { 504 - http.Error(w, "bad issue id", http.StatusBadRequest) 505 - log.Println("failed to parse issue id", err) 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 506 621 return 507 622 } 508 623 509 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 510 629 if err != nil { 511 - log.Println("failed to get issue", err) 512 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 513 632 return 514 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) 637 + return 638 + } 639 + comment := comments[0] 515 640 516 - commentId := chi.URLParam(r, "comment_id") 517 - commentIdInt, err := strconv.Atoi(commentId) 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) 518 653 if err != nil { 519 - http.Error(w, "bad comment id", http.StatusBadRequest) 520 - log.Println("failed to parse issue id", err) 654 + l.Error("failed to get repo and knot", "err", err) 521 655 return 522 656 } 523 657 524 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 662 + return 663 + } 664 + 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 525 670 if err != nil { 526 - 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) 527 673 return 528 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] 529 681 530 - if comment.OwnerDid != user.Did { 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 531 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 532 685 return 533 686 } ··· 539 692 540 693 // optimistic deletion 541 694 deleted := time.Now() 542 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 543 696 if err != nil { 544 - log.Println("failed to delete comment") 697 + l.Error("failed to delete comment", "err", err) 545 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 546 699 return 547 700 } ··· 555 708 return 556 709 } 557 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 558 - Collection: tangled.GraphFollowNSID, 711 + Collection: tangled.RepoIssueCommentNSID, 559 712 Repo: user.Did, 560 713 Rkey: comment.Rkey, 561 714 }) ··· 569 722 comment.Deleted = &deleted 570 723 571 724 // htmx fragment of comment after deletion 572 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 573 726 LoggedInUser: user, 574 727 RepoInfo: f.RepoInfo(user), 575 728 Issue: issue, 576 - Comment: comment, 729 + Comment: &comment, 577 730 }) 578 731 } 579 732 ··· 603 756 return 604 757 } 605 758 606 - 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 + ) 607 769 if err != nil { 608 770 log.Println("failed to get issues", err) 609 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 620 782 } 621 783 622 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 623 786 user := rp.oauth.GetUser(r) 624 787 625 788 f, err := rp.repoResolver.Resolve(r) 626 789 if err != nil { 627 - log.Println("failed to get repo and knot", err) 790 + l.Error("failed to get repo and knot", "err", err) 628 791 return 629 792 } 630 793 ··· 635 798 RepoInfo: f.RepoInfo(user), 636 799 }) 637 800 case http.MethodPost: 638 - title := r.FormValue("title") 639 - body := r.FormValue("body") 640 - 641 - if title == "" || body == "" { 642 - rp.pages.Notice(w, "issues", "Title and body are required") 643 - 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(), 644 808 } 645 809 646 - tx, err := rp.db.BeginTx(r.Context(), nil) 647 - if err != nil { 648 - 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)) 649 813 return 650 814 } 651 815 652 - issue := &db.Issue{ 653 - RepoAt: f.RepoAt, 654 - Title: title, 655 - Body: body, 656 - OwnerDid: user.Did, 657 - } 658 - err = db.NewIssue(tx, issue) 659 - if err != nil { 660 - log.Println("failed to create issue", err) 661 - rp.pages.Notice(w, "issues", "Failed to create issue.") 662 - return 663 - } 816 + record := issue.AsRecord() 664 817 818 + // create an atproto record 665 819 client, err := rp.oauth.AuthorizedClient(r) 666 820 if err != nil { 667 - log.Println("failed to get authorized client", err) 821 + l.Error("failed to get authorized client", "err", err) 668 822 rp.pages.Notice(w, "issues", "Failed to create issue.") 669 823 return 670 824 } 671 - atUri := f.RepoAt.String() 672 825 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 673 826 Collection: tangled.RepoIssueNSID, 674 827 Repo: user.Did, 675 - Rkey: tid.TID(), 828 + Rkey: issue.Rkey, 676 829 Record: &lexutil.LexiconTypeDecoder{ 677 - Val: &tangled.RepoIssue{ 678 - Repo: atUri, 679 - Title: title, 680 - Body: &body, 681 - Owner: user.Did, 682 - IssueId: int64(issue.IssueId), 683 - }, 830 + Val: &record, 684 831 }, 685 832 }) 686 833 if err != nil { 834 + l.Error("failed to create issue", "err", err) 835 + rp.pages.Notice(w, "issues", "Failed to create issue.") 836 + return 837 + } 838 + atUri := resp.Uri 839 + 840 + tx, err := rp.db.BeginTx(r.Context(), nil) 841 + if err != nil { 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 { 687 861 log.Println("failed to create issue", err) 688 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 689 863 return 690 864 } 691 865 692 - err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 693 - if err != nil { 694 - log.Println("failed to set issue at", err) 866 + if err = tx.Commit(); err != nil { 867 + log.Println("failed to create issue", err) 695 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 696 869 return 697 870 } 698 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 699 874 rp.notifier.NewIssue(r.Context(), issue) 700 - 701 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 702 876 return 703 877 } 704 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
+416 -218
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 95 + } 96 + if len(registrations) != 1 { 97 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 98 + return 100 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) 223 276 fail() 224 277 return 225 278 } 279 + if len(registrations) != 1 { 280 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 281 + fail() 282 + return 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) 288 - 289 - user := k.OAuth.GetUser(r) 290 - l = l.With("did", user.Did) 375 + l = l.With("user", user.Did) 291 376 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 } 432 + 433 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + var exCid *string 435 + if ex != nil { 436 + exCid = ex.Cid 437 + } 438 + 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) 453 + } 318 454 } 319 455 320 - repos, err := db.GetRepos( 456 + // add this knot to knotstream 457 + go k.Knotstream.AddSource( 458 + r.Context(), 459 + eventconsumer.NewKnotSource(domain), 460 + ) 461 + 462 + shouldRefresh := r.Header.Get("shouldRefresh") 463 + if shouldRefresh == "true" { 464 + k.Pages.HxRefresh(w) 465 + return 466 + } 467 + 468 + // Get updated registration to show 469 + registrations, err = db.GetRegistrations( 321 470 k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 471 + db.FilterEq("did", user.Did), 472 + db.FilterEq("domain", domain), 325 473 ) 326 474 if err != nil { 327 - l.Error("failed to get repos list", "err", err) 475 + l.Error("failed to get registration", "err", err) 328 476 fail() 329 477 return 330 478 } 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) 479 + if len(registrations) != 1 { 480 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 481 + fail() 482 + return 335 483 } 484 + updatedRegistration := registrations[0] 336 485 337 - k.Pages.Knot(w, pages.KnotParams{ 338 - LoggedInUser: user, 339 - Registration: reg, 340 - Members: members, 341 - Repos: repoByMember, 342 - IsOwner: true, 486 + w.Header().Set("HX-Reswap", "outerHTML") 487 + k.Pages.KnotListing(w, pages.KnotListingParams{ 488 + Registration: &updatedRegistration, 343 489 }) 344 490 } 345 491 346 - // list members of domain, requires auth and requires owner status 347 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 348 - l := k.Logger.With("handler", "members") 492 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 493 + user := k.OAuth.GetUser(r) 494 + l := k.Logger.With("handler", "addMember") 349 495 350 496 domain := chi.URLParam(r, "domain") 351 497 if domain == "" { 352 - http.Error(w, "malformed url", http.StatusBadRequest) 498 + l.Error("empty domain") 499 + http.Error(w, "Not found", http.StatusNotFound) 353 500 return 354 501 } 355 502 l = l.With("domain", domain) 503 + l = l.With("user", user.Did) 356 504 357 - // list all members for this domain 358 - memberDids, err := k.Enforcer.GetUserByRole("server:member", 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 + ) 359 511 if err != nil { 360 - w.Write([]byte("failed to fetch member list")) 512 + l.Error("failed to get registration", "err", err) 361 513 return 362 514 } 363 - 364 - w.Write([]byte(strings.Join(memberDids, "\n"))) 365 - } 366 - 367 - // add member to domain, requires auth and requires invite access 368 - func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 369 - l := k.Logger.With("handler", "members") 370 - 371 - domain := chi.URLParam(r, "domain") 372 - if domain == "" { 373 - http.Error(w, "malformed url", http.StatusBadRequest) 515 + if len(registrations) != 1 { 516 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 374 517 return 375 518 } 376 - l = l.With("domain", domain) 377 - 378 - reg, err := db.RegistrationByDomain(k.Db, domain) 379 - if err != nil { 380 - l.Error("failed to get registration by domain", "err", err) 381 - http.Error(w, "malformed url", http.StatusBadRequest) 382 - return 383 - } 519 + registration := registrations[0] 384 520 385 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 386 - l = l.With("notice-id", noticeId) 521 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 387 522 defaultErr := "Failed to add member. Try again later." 388 523 fail := func() { 389 524 k.Pages.Notice(w, noticeId, defaultErr) 390 525 } 391 526 392 - subjectIdentifier := r.FormValue("subject") 393 - if subjectIdentifier == "" { 394 - 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.") 395 531 return 396 532 } 397 - l = l.With("subjectIdentifier", subjectIdentifier) 533 + l = l.With("member", member) 398 534 399 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 535 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 400 536 if err != nil { 401 - l.Error("failed to resolve identity", "err", err) 537 + l.Error("failed to resolve member identity to handle", "err", err) 402 538 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 403 539 return 404 540 } 405 - l = l.With("subjectDid", subjectIdentity.DID) 406 - 407 - 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 + } 408 546 409 - // announce this relation into the firehose, store into owners' pds 547 + // write to pds 410 548 client, err := k.OAuth.AuthorizedClient(r) 411 549 if err != nil { 412 - l.Error("failed to create client", "err", err) 550 + l.Error("failed to authorize client", "err", err) 413 551 fail() 414 552 return 415 553 } 416 554 417 - currentUser := k.OAuth.GetUser(r) 418 - createdAt := time.Now().Format(time.RFC3339) 419 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 555 + rkey := tid.TID() 556 + 557 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 420 558 Collection: tangled.KnotMemberNSID, 421 - Repo: currentUser.Did, 422 - Rkey: tid.TID(), 559 + Repo: user.Did, 560 + Rkey: rkey, 423 561 Record: &lexutil.LexiconTypeDecoder{ 424 562 Val: &tangled.KnotMember{ 425 - Subject: subjectIdentity.DID.String(), 563 + CreatedAt: time.Now().Format(time.RFC3339), 426 564 Domain: domain, 427 - CreatedAt: createdAt, 428 - }}, 565 + Subject: memberId.DID.String(), 566 + }, 567 + }, 429 568 }) 430 - // invalid record 431 569 if err != nil { 432 - l.Error("failed to write to PDS", "err", err) 433 - 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.") 434 572 return 435 573 } 436 - l = l.With("at-uri", resp.Uri) 437 - l.Info("wrote record to PDS") 438 574 439 - secret, err := db.GetRegistrationKey(k.Db, domain) 575 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 440 576 if err != nil { 441 - l.Error("failed to get registration key", "err", err) 577 + l.Error("failed to add member to ACLs", "err", err) 442 578 fail() 443 579 return 444 580 } 445 581 446 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 582 + err = k.Enforcer.E.SavePolicy() 447 583 if err != nil { 448 - l.Error("failed to create client", "err", err) 584 + l.Error("failed to save ACL policy", "err", err) 449 585 fail() 450 586 return 451 587 } 452 588 453 - 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 + ) 454 618 if err != nil { 455 - l.Error("failed to reach knotserver", "err", err) 456 - 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.") 457 644 return 458 645 } 459 646 460 - if ksResp.StatusCode != http.StatusNoContent { 461 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 462 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 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() 463 652 return 464 653 } 465 654 466 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 655 + client, err := k.OAuth.AuthorizedClient(r) 467 656 if err != nil { 468 - l.Error("failed to add member to enforcer", "err", err) 657 + l.Error("failed to authorize client", "err", err) 469 658 fail() 470 659 return 471 660 } 472 661 473 - // success 474 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 475 - } 662 + // TODO: We need to track the rkey for knot members to delete the record 663 + // For now, just remove from ACLs 664 + _ = client 665 + 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 + } 476 673 477 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 674 + // ok 675 + k.Pages.HxRefresh(w) 478 676 }
+56 -13
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 } ··· 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" {
+18
appview/notify/merged_notifier.go
··· 66 66 notifier.UpdateProfile(ctx, profile) 67 67 } 68 68 } 69 + 70 + func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) { 71 + for _, notifier := range m.notifiers { 72 + notifier.NewString(ctx, string) 73 + } 74 + } 75 + 76 + func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) { 77 + for _, notifier := range m.notifiers { 78 + notifier.EditString(ctx, string) 79 + } 80 + } 81 + 82 + func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 + for _, notifier := range m.notifiers { 84 + notifier.DeleteString(ctx, did, rkey) 85 + } 86 + }
+8
appview/notify/notifier.go
··· 21 21 NewPullComment(ctx context.Context, comment *db.PullComment) 22 22 23 23 UpdateProfile(ctx context.Context, profile *db.Profile) 24 + 25 + NewString(ctx context.Context, s *db.String) 26 + EditString(ctx context.Context, s *db.String) 27 + DeleteString(ctx context.Context, did, rkey string) 24 28 } 25 29 26 30 // BaseNotifier is a listener that does nothing ··· 42 46 func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 43 47 44 48 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 49 + 50 + func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {} 51 + func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {} 52 + func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+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 + }
+26 -4
appview/pages/funcmap.go
··· 19 19 20 20 "github.com/dustin/go-humanize" 21 21 "github.com/go-enry/go-enry/v2" 22 - "github.com/microcosm-cc/bluemonday" 23 22 "tangled.sh/tangled.sh/core/appview/filetree" 24 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 + "tangled.sh/tangled.sh/core/crypto" 25 25 ) 26 26 27 27 func (p *Pages) funcMap() template.FuncMap { 28 28 return template.FuncMap{ 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 + }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 31 34 }, 32 35 "resolve": func(s string) string { 33 36 identity, err := p.resolver.ResolveIdent(context.Background(), s) ··· 207 210 } 208 211 return v.Slice(0, min(n, v.Len())).Interface() 209 212 }, 210 - 211 213 "markdown": func(text string) template.HTML { 212 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 213 - 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) 214 224 }, 215 225 "isNil": func(t any) bool { 216 226 // returns false for other "zero" values ··· 270 280 }, 271 281 "layoutCenter": func() string { 272 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 273 295 }, 274 296 } 275 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 + url.PathEscape(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 + }
+378 -237
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" ··· 42 42 var Files embed.FS 43 43 44 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 47 48 48 avatar config.AvatarConfig 49 49 resolver *idresolver.Resolver 50 50 dev bool 51 - embedFS embed.FS 51 + embedFS fs.FS 52 52 templateDir string // Path to templates on disk for dev mode 53 53 rctx *markup.RenderContext 54 + logger *slog.Logger 54 55 } 55 56 56 57 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 59 60 IsDev: config.Core.Dev, 60 61 CamoUrl: config.Camo.Host, 61 62 CamoSecret: config.Camo.SharedSecret, 63 + Sanitizer: markup.NewSanitizer(), 62 64 } 63 65 64 66 p := &Pages{ 65 67 mu: sync.RWMutex{}, 66 - t: make(map[string]*template.Template), 68 + cache: NewTmplCache[string, *template.Template](), 67 69 dev: config.Core.Dev, 68 70 avatar: config.Avatar, 69 - embedFS: Files, 70 71 rctx: rctx, 71 72 resolver: res, 72 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 73 75 } 74 76 75 - // Initial load of all templates 76 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 77 82 78 83 return p 79 84 } 80 85 81 - func (p *Pages) loadAllTemplates() { 82 - templates := make(map[string]*template.Template) 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 + 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 83 96 var fragmentPaths []string 84 - 85 - // Use embedded FS for initial loading 86 - // First, collect all fragment paths 87 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 88 98 if err != nil { 89 99 return err ··· 97 107 if !strings.Contains(path, "fragments/") { 98 108 return nil 99 109 } 100 - name := strings.TrimPrefix(path, "templates/") 101 - name = strings.TrimSuffix(name, ".html") 102 - tmpl, err := template.New(name). 103 - Funcs(p.funcMap()). 104 - ParseFS(p.embedFS, path) 105 - if err != nil { 106 - log.Fatalf("setting up fragment: %v", err) 107 - } 108 - templates[name] = tmpl 109 110 fragmentPaths = append(fragmentPaths, path) 110 - log.Printf("loaded fragment: %s", name) 111 111 return nil 112 112 }) 113 113 if err != nil { 114 - log.Fatalf("walking template dir for fragments: %v", err) 114 + return nil, err 115 115 } 116 116 117 - // Then walk through and setup the rest of the templates 118 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 119 - if err != nil { 120 - return err 121 - } 122 - if d.IsDir() { 123 - return nil 124 - } 125 - if !strings.HasSuffix(path, "html") { 126 - return nil 127 - } 128 - // Skip fragments as they've already been loaded 129 - if strings.Contains(path, "fragments/") { 130 - return nil 131 - } 132 - // Skip layouts 133 - if strings.Contains(path, "layouts/") { 134 - return nil 135 - } 136 - name := strings.TrimPrefix(path, "templates/") 137 - name = strings.TrimSuffix(name, ".html") 138 - // Add the page template on top of the base 139 - allPaths := []string{} 140 - allPaths = append(allPaths, "templates/layouts/*.html") 141 - allPaths = append(allPaths, fragmentPaths...) 142 - allPaths = append(allPaths, path) 143 - tmpl, err := template.New(name). 144 - Funcs(p.funcMap()). 145 - ParseFS(p.embedFS, allPaths...) 146 - if err != nil { 147 - return fmt.Errorf("setting up template: %w", err) 148 - } 149 - templates[name] = tmpl 150 - log.Printf("loaded template: %s", name) 151 - return nil 152 - }) 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...) 153 135 if err != nil { 154 - log.Fatalf("walking template dir: %v", err) 136 + return nil, err 155 137 } 156 138 157 - log.Printf("total templates loaded: %d", len(templates)) 158 - p.mu.Lock() 159 - defer p.mu.Unlock() 160 - p.t = templates 139 + return parsed, nil 161 140 } 162 141 163 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 164 - func (p *Pages) loadTemplateFromDisk(name string) error { 165 - if !p.dev { 166 - 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 167 148 } 168 149 169 - log.Printf("reloading template from disk: %s", name) 170 - 171 - // Find all fragments first 172 - var fragmentPaths []string 173 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 174 - if err != nil { 175 - return err 176 - } 177 - if d.IsDir() { 178 - return nil 179 - } 180 - if !strings.HasSuffix(path, ".html") { 181 - return nil 182 - } 183 - if !strings.Contains(path, "fragments/") { 184 - return nil 185 - } 186 - fragmentPaths = append(fragmentPaths, path) 187 - return nil 188 - }) 150 + result, err := p.rawParse(stack...) 189 151 if err != nil { 190 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 152 + return nil, err 191 153 } 192 154 193 - // Find the template path on disk 194 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 195 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 196 - return fmt.Errorf("template not found on disk: %s", name) 197 - } 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 198 158 199 - // Create a new template 200 - tmpl := template.New(name).Funcs(p.funcMap()) 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 163 + } 164 + return p.parse(stack...) 165 + } 201 166 202 - // Parse layouts 203 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 204 - layouts, err := filepath.Glob(layoutGlob) 205 - if err != nil { 206 - return fmt.Errorf("finding layout templates: %w", err) 167 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 168 + stack := []string{ 169 + "layouts/base", 170 + "layouts/repobase", 171 + top, 207 172 } 173 + return p.parse(stack...) 174 + } 208 175 209 - // Create paths for parsing 210 - allFiles := append(layouts, fragmentPaths...) 211 - allFiles = append(allFiles, templatePath) 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 + } 212 184 213 - // Parse all templates 214 - tmpl, err = tmpl.ParseFiles(allFiles...) 185 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 186 + tpl, err := p.parse(name) 215 187 if err != nil { 216 - return fmt.Errorf("parsing template files: %w", err) 188 + return err 217 189 } 218 190 219 - // Update the template in the map 220 - p.mu.Lock() 221 - defer p.mu.Unlock() 222 - p.t[name] = tmpl 223 - log.Printf("template reloaded from disk: %s", name) 224 - return nil 191 + return tpl.Execute(w, params) 225 192 } 226 193 227 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 228 - // In dev mode, reload the template from disk before executing 229 - if p.dev { 230 - if err := p.loadTemplateFromDisk(templateName); err != nil { 231 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 232 - // Continue with the existing template 233 - } 194 + func (p *Pages) execute(name string, w io.Writer, params any) error { 195 + tpl, err := p.parseBase(name) 196 + if err != nil { 197 + return err 234 198 } 235 199 236 - p.mu.RLock() 237 - defer p.mu.RUnlock() 238 - tmpl, exists := p.t[templateName] 239 - if !exists { 240 - return fmt.Errorf("template not found: %s", templateName) 241 - } 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 201 + } 242 202 243 - if base == "" { 244 - return tmpl.Execute(w, params) 245 - } else { 246 - return tmpl.ExecuteTemplate(w, base, params) 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 247 207 } 248 - } 249 208 250 - func (p *Pages) execute(name string, w io.Writer, params any) error { 251 - return p.executeOrReload(name, w, "layouts/base", params) 209 + return tpl.ExecuteTemplate(w, "layouts/base", params) 252 210 } 253 211 254 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 255 - return p.executeOrReload(name, w, "", 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 216 + } 217 + 218 + return tpl.ExecuteTemplate(w, "layouts/base", params) 256 219 } 257 220 258 - func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 259 - return p.executeOrReload(name, w, "layouts/repobase", params) 221 + func (p *Pages) Favicon(w io.Writer) error { 222 + return p.executePlain("favicon", w, nil) 260 223 } 261 224 262 225 type LoginParams struct { 226 + ReturnUrl string 263 227 } 264 228 265 229 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 276 240 277 241 type TermsOfServiceParams struct { 278 242 LoggedInUser *oauth.User 243 + Content template.HTML 279 244 } 280 245 281 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 + 282 259 return p.execute("legal/terms", w, params) 283 260 } 284 261 285 262 type PrivacyPolicyParams struct { 286 263 LoggedInUser *oauth.User 264 + Content template.HTML 287 265 } 288 266 289 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 + 290 280 return p.execute("legal/privacy", w, params) 291 281 } 292 282 293 283 type TimelineParams struct { 294 284 LoggedInUser *oauth.User 295 285 Timeline []db.TimelineEvent 286 + Repos []db.Repo 296 287 } 297 288 298 289 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 299 - 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 300 297 } 301 298 302 - 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 { 303 304 LoggedInUser *oauth.User 304 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 305 316 Emails []db.Email 317 + Tabs []map[string]any 318 + Tab string 306 319 } 307 320 308 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 309 - return p.execute("settings", w, params) 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 328 + } 329 + 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 310 332 } 311 333 312 334 type KnotsParams struct { ··· 331 353 } 332 354 333 355 type KnotListingParams struct { 334 - db.Registration 356 + *db.Registration 335 357 } 336 358 337 359 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 338 360 return p.executePlain("knots/fragments/knotListing", w, params) 339 361 } 340 362 341 - type KnotListingFullParams struct { 342 - Registrations []db.Registration 343 - } 344 - 345 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 346 - return p.executePlain("knots/fragments/knotListingFull", w, params) 347 - } 348 - 349 - type KnotSecretParams struct { 350 - Secret string 351 - } 352 - 353 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 354 - return p.executePlain("knots/fragments/secret", w, params) 355 - } 356 - 357 363 type SpindlesParams struct { 358 364 LoggedInUser *oauth.User 359 365 Spindles []db.Spindle ··· 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 411 446 } 412 447 413 - type ProfileCard struct { 414 - UserDid string 415 - UserHandle string 416 - FollowStatus db.FollowStatus 417 - Followers int 418 - Following int 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 451 + } 419 452 420 - Profile *db.Profile 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 421 458 } 422 459 423 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 424 - 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) 425 463 } 426 464 427 - type ReposPageParams struct { 465 + type ProfileStarredParams struct { 428 466 LoggedInUser *oauth.User 429 467 Repos []db.Repo 430 - Card ProfileCard 468 + Card *ProfileCard 469 + Active string 431 470 } 432 471 433 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 434 - return p.execute("user/repos", w, params) 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 514 + } 515 + 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 435 519 } 436 520 437 521 type FollowFragmentParams struct { ··· 490 574 } 491 575 492 576 type RepoIndexParams struct { 493 - LoggedInUser *oauth.User 494 - RepoInfo repoinfo.RepoInfo 495 - Active string 496 - TagMap map[string][]string 497 - CommitsTrunc []*object.Commit 498 - TagsTrunc []*types.TagReference 499 - BranchesTrunc []types.Branch 500 - 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 501 585 HTMLReadme template.HTML 502 586 Raw bool 503 587 EmailToDidOrHandle map[string]string 504 588 VerifiedCommits commitverify.VerifiedCommits 505 589 Languages []types.RepoLanguageDetails 506 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 507 592 types.RepoIndexResponse 508 593 } 509 594 ··· 511 596 params.Active = "overview" 512 597 if params.IsEmpty { 513 598 return p.executeRepo("repo/empty", w, params) 599 + } 600 + 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 514 603 } 515 604 516 605 p.rctx.RepoInfo = params.RepoInfo 606 + p.rctx.RepoInfo.Ref = params.Ref 517 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 518 608 519 609 if params.ReadmeFileName != "" { 520 - var htmlString string 521 610 ext := filepath.Ext(params.ReadmeFileName) 522 611 switch ext { 523 612 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 524 - htmlString = p.rctx.Sanitize(htmlString) 525 - htmlString = p.rctx.RenderMarkdown(params.Readme) 526 613 params.Raw = false 527 - params.HTMLReadme = template.HTML(htmlString) 614 + htmlString := p.rctx.RenderMarkdown(params.Readme) 615 + sanitized := p.rctx.SanitizeDefault(htmlString) 616 + params.HTMLReadme = template.HTML(sanitized) 528 617 default: 529 618 params.Raw = true 530 619 } ··· 600 689 601 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 602 691 params.Active = "overview" 603 - return p.execute("repo/tree", w, params) 692 + return p.executeRepo("repo/tree", w, params) 604 693 } 605 694 606 695 type RepoBranchesParams struct { ··· 651 740 ShowRendered bool 652 741 RenderToggle bool 653 742 RenderedContents template.HTML 654 - types.RepoBlobResponse 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 655 749 } 656 750 657 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 663 757 p.rctx.RepoInfo = params.RepoInfo 664 758 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 665 759 htmlString := p.rctx.RenderMarkdown(params.Contents) 666 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 760 + sanitized := p.rctx.SanitizeDefault(htmlString) 761 + params.RenderedContents = template.HTML(sanitized) 667 762 } 668 763 } 669 764 670 - if params.Lines < 5000 { 671 - c := params.Contents 672 - formatter := chromahtml.New( 673 - chromahtml.InlineCode(false), 674 - chromahtml.WithLineNumbers(true), 675 - chromahtml.WithLinkableLineNumbers(true, "L"), 676 - chromahtml.Standalone(false), 677 - chromahtml.WithClasses(true), 678 - ) 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 + ) 679 773 680 - lexer := lexers.Get(filepath.Base(params.Path)) 681 - if lexer == nil { 682 - lexer = lexers.Fallback 683 - } 774 + lexer := lexers.Get(filepath.Base(params.Path)) 775 + if lexer == nil { 776 + lexer = lexers.Fallback 777 + } 684 778 685 - iterator, err := lexer.Tokenise(nil, c) 686 - if err != nil { 687 - return fmt.Errorf("chroma tokenize: %w", err) 688 - } 779 + iterator, err := lexer.Tokenise(nil, c) 780 + if err != nil { 781 + return fmt.Errorf("chroma tokenize: %w", err) 782 + } 689 783 690 - var code bytes.Buffer 691 - err = formatter.Format(&code, style, iterator) 692 - if err != nil { 693 - return fmt.Errorf("chroma format: %w", err) 694 - } 695 - 696 - 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) 697 788 } 698 789 790 + params.Contents = code.String() 699 791 params.Active = "overview" 700 792 return p.executeRepo("repo/blob", w, params) 701 793 } ··· 787 879 LoggedInUser *oauth.User 788 880 RepoInfo repoinfo.RepoInfo 789 881 Active string 790 - Issue db.Issue 791 - Comments []db.Comment 882 + Issue *db.Issue 883 + CommentList []db.CommentListItem 792 884 IssueOwnerHandle string 793 885 794 886 OrderedReactionKinds []db.ReactionKind 795 887 Reactions map[db.ReactionKind]int 796 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 + } 797 895 798 - 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) 799 906 } 800 907 801 908 type ThreadReactionFragmentParams struct { ··· 807 914 808 915 func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 809 916 return p.executePlain("repo/fragments/reaction", w, params) 810 - } 811 - 812 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 813 - params.Active = "issues" 814 - if params.Issue.Open { 815 - params.State = "open" 816 - } else { 817 - params.State = "closed" 818 - } 819 - return p.execute("repo/issues/issue", w, params) 820 917 } 821 918 822 919 type RepoNewIssueParams struct { 823 920 LoggedInUser *oauth.User 824 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 825 923 Active string 924 + Action string 826 925 } 827 926 828 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 829 928 params.Active = "issues" 929 + params.Action = "create" 830 930 return p.executeRepo("repo/issues/new", w, params) 831 931 } 832 932 ··· 834 934 LoggedInUser *oauth.User 835 935 RepoInfo repoinfo.RepoInfo 836 936 Issue *db.Issue 837 - Comment *db.Comment 937 + Comment *db.IssueComment 838 938 } 839 939 840 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 841 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 842 942 } 843 943 844 - type SingleIssueCommentParams struct { 944 + type ReplyIssueCommentPlaceholderParams struct { 945 + LoggedInUser *oauth.User 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 { 845 967 LoggedInUser *oauth.User 846 968 RepoInfo repoinfo.RepoInfo 847 969 Issue *db.Issue 848 - Comment *db.Comment 970 + Comment *db.IssueComment 849 971 } 850 972 851 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 852 - 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) 853 975 } 854 976 855 977 type RepoNewPullParams struct { ··· 1154 1276 return p.execute("strings/dashboard", w, params) 1155 1277 } 1156 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 + 1157 1288 type SingleStringParams struct { 1158 1289 LoggedInUser *oauth.User 1159 1290 ShowRendered bool ··· 1170 1301 if params.ShowRendered { 1171 1302 switch markup.GetFormat(params.String.Filename) { 1172 1303 case markup.FormatMarkdown: 1173 - p.rctx.RendererType = markup.RendererTypeDefault 1304 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1174 1305 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1175 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1306 + sanitized := p.rctx.SanitizeDefault(htmlString) 1307 + params.RenderedContents = template.HTML(sanitized) 1176 1308 } 1177 1309 } 1178 1310 ··· 1205 1337 return p.execute("strings/string", w, params) 1206 1338 } 1207 1339 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1208 1344 func (p *Pages) Static() http.Handler { 1209 1345 if p.dev { 1210 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1212 1348 1213 1349 sub, err := fs.Sub(Files, "static") 1214 1350 if err != nil { 1215 - 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) 1216 1353 } 1217 1354 // Custom handler to apply Cache-Control headers for font files 1218 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1235 1372 func CssContentHash() string { 1236 1373 cssFile, err := Files.Open("static/tw.css") 1237 1374 if err != nil { 1238 - log.Printf("Error opening CSS file: %v", err) 1375 + slog.Debug("Error opening CSS file", "err", err) 1239 1376 return "" 1240 1377 } 1241 1378 defer cssFile.Close() 1242 1379 1243 1380 hasher := sha256.New() 1244 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1245 - log.Printf("Error hashing CSS file: %v", err) 1382 + slog.Debug("Error hashing CSS file", "err", err) 1246 1383 return "" 1247 1384 } 1248 1385 ··· 1255 1392 1256 1393 func (p *Pages) Error404(w io.Writer) error { 1257 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) 1258 1399 } 1259 1400 1260 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.md"> 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 }}
+8
appview/pages/templates/fragments/logotype.html
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + <span class="font-bold italic">tangled</span> 4 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 + alpha 6 + </span> 7 + <span> 8 + {{ end }}
+90
appview/pages/templates/fragments/multiline-select.html
··· 1 + {{ define "fragments/multiline-select" }} 2 + <script> 3 + function highlight(scroll = false) { 4 + document.querySelectorAll(".hl").forEach(el => { 5 + el.classList.remove("hl"); 6 + }); 7 + 8 + const hash = window.location.hash; 9 + if (!hash || !hash.startsWith("#L")) { 10 + return; 11 + } 12 + 13 + const rangeStr = hash.substring(2); 14 + const parts = rangeStr.split("-"); 15 + let startLine, endLine; 16 + 17 + if (parts.length === 2) { 18 + startLine = parseInt(parts[0], 10); 19 + endLine = parseInt(parts[1], 10); 20 + } else { 21 + startLine = parseInt(parts[0], 10); 22 + endLine = startLine; 23 + } 24 + 25 + if (isNaN(startLine) || isNaN(endLine)) { 26 + console.log("nan"); 27 + console.log(startLine); 28 + console.log(endLine); 29 + return; 30 + } 31 + 32 + let target = null; 33 + 34 + for (let i = startLine; i<= endLine; i++) { 35 + const idEl = document.getElementById(`L${i}`); 36 + if (idEl) { 37 + const el = idEl.closest(".line"); 38 + if (el) { 39 + el.classList.add("hl"); 40 + target = el; 41 + } 42 + } 43 + } 44 + 45 + if (scroll && target) { 46 + target.scrollIntoView({ 47 + behavior: "smooth", 48 + block: "center", 49 + }); 50 + } 51 + } 52 + 53 + document.addEventListener("DOMContentLoaded", () => { 54 + console.log("DOMContentLoaded"); 55 + highlight(true); 56 + }); 57 + window.addEventListener("hashchange", () => { 58 + console.log("hashchange"); 59 + highlight(); 60 + }); 61 + window.addEventListener("popstate", () => { 62 + console.log("popstate"); 63 + highlight(); 64 + }); 65 + 66 + const lineNumbers = document.querySelectorAll('a[href^="#L"'); 67 + let startLine = null; 68 + 69 + lineNumbers.forEach(el => { 70 + el.addEventListener("click", (event) => { 71 + event.preventDefault(); 72 + const currentLine = parseInt(el.href.split("#L")[1]); 73 + 74 + if (event.shiftKey && startLine !== null) { 75 + const endLine = currentLine; 76 + const min = Math.min(startLine, endLine); 77 + const max = Math.max(startLine, endLine); 78 + const newHash = `#L${min}-${max}`; 79 + history.pushState(null, '', newHash); 80 + } else { 81 + const newHash = `#L${currentLine}`; 82 + history.pushState(null, '', newHash); 83 + startLine = currentLine; 84 + } 85 + 86 + highlight(); 87 + }); 88 + }); 89 + </script> 90 + {{ end }}
+93 -28
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 }} 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 }} 13 + {{ template "knots/fragments/addMemberModal" .Registration }} 14 + {{ end }} 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 12 18 </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 18 - {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 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 }} 21 26 {{ end }} 22 - </div> 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"> ··· 41 52 {{ template "user/fragments/picHandleLink" . }} 42 53 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 44 58 </div> 45 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 60 {{ $repos := index $.Repos . }} ··· 53 67 </div> 54 68 {{ else }} 55 69 <div class="text-gray-500 dark:text-gray-400"> 56 - No repositories created yet. 70 + No repositories configured yet. 57 71 </div> 58 72 {{ end }} 59 73 </div> 60 74 </div> 61 75 {{ end }} 62 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 }}
+34 -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-center justify-between gap-4 align-bottom"> 5 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 13 <div class="flex flex-col gap-6"> 10 14 {{ block "about" . }} {{ end }} 11 - {{ template "knots/fragments/knotListingFull" . }} 15 + {{ block "list" . }} {{ end }} 12 16 {{ block "register" . }} {{ end }} 13 17 </div> 14 18 </section> 15 19 {{ end }} 16 20 17 21 {{ 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> 22 + <section class="rounded"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 25 + When creating a repository, you can choose a knot to store it on. 26 26 </p> 27 + 28 + 29 + </section> 30 + {{ end }} 31 + 32 + {{ define "list" }} 33 + <section class="rounded w-full flex flex-col gap-2"> 34 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 35 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 36 + {{ range $registration := .Registrations }} 37 + {{ template "knots/fragments/knotListing" . }} 38 + {{ else }} 39 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 40 + no knots registered yet 41 + </div> 42 + {{ end }} 43 + </div> 44 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 27 45 </section> 28 46 {{ end }} 29 47 30 48 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 49 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 32 50 <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> 51 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 34 52 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 53 + hx-post="/knots/register" 54 + class="max-w-2xl mb-2 space-y-4" 37 55 hx-indicator="#register-button" 38 56 hx-swap="none" 39 57 > ··· 53 71 > 54 72 <span class="inline-flex items-center gap-2"> 55 73 {{ i "plus" "w-4 h-4" }} 56 - generate 74 + register 57 75 </span> 58 76 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 77 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 79 </button> 62 80 </div> 63 81 64 - <div id="registration-error" class="error dark:text-red-400"></div> 82 + <div id="register-error" class="error dark:text-red-400"></div> 65 83 </form> 66 84 67 - <div id="secret"></div> 68 85 </section> 69 86 {{ 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 }}
+78
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="text-lg">{{ template "fragments/logotype" }}</a> 6 + </div> 7 + 8 + <div id="right-items" class="flex items-center gap-2"> 9 + {{ with .LoggedInUser }} 10 + {{ block "newButton" . }} {{ end }} 11 + {{ block "dropDown" . }} {{ end }} 12 + {{ else }} 13 + <a href="/login">login</a> 14 + <span class="text-gray-500 dark:text-gray-400">or</span> 15 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 16 + join now {{ i "arrow-right" "size-4" }} 17 + </a> 18 + {{ end }} 19 + </div> 20 + </div> 21 + </nav> 22 + {{ end }} 23 + 24 + {{ define "newButton" }} 25 + <details class="relative inline-block text-left nav-dropdown"> 26 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 27 + {{ i "plus" "w-4 h-4" }} new 28 + </summary> 29 + <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"> 30 + <a href="/repo/new" class="flex items-center gap-2"> 31 + {{ i "book-plus" "w-4 h-4" }} 32 + new repository 33 + </a> 34 + <a href="/strings/new" class="flex items-center gap-2"> 35 + {{ i "line-squiggle" "w-4 h-4" }} 36 + new string 37 + </a> 38 + </div> 39 + </details> 40 + {{ end }} 41 + 42 + {{ define "dropDown" }} 43 + <details class="relative inline-block text-left nav-dropdown"> 44 + <summary 45 + class="cursor-pointer list-none flex items-center" 46 + > 47 + {{ $user := didOrHandle .Did .Handle }} 48 + {{ template "user/fragments/picHandle" $user }} 49 + </summary> 50 + <div 51 + 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" 52 + > 53 + <a href="/{{ $user }}">profile</a> 54 + <a href="/{{ $user }}?tab=repos">repositories</a> 55 + <a href="/{{ $user }}?tab=strings">strings</a> 56 + <a href="/knots">knots</a> 57 + <a href="/spindles">spindles</a> 58 + <a href="/settings">settings</a> 59 + <a href="#" 60 + hx-post="/logout" 61 + hx-swap="none" 62 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 63 + logout 64 + </a> 65 + </div> 66 + </details> 67 + 68 + <script> 69 + document.addEventListener('click', function(event) { 70 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 71 + dropdowns.forEach(function(dropdown) { 72 + if (!dropdown.contains(event.target)) { 73 + dropdown.removeAttribute('open'); 74 + } 75 + }); 76 + }); 77 + </script> 78 + {{ end }}
+109
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 px-2 py-6 md: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 + {{ $style := "hidden md:block md:col-span-3" }} 15 + {{ if eq $.Active "overview" }} 16 + {{ $style = "md:col-span-3" }} 17 + {{ end }} 18 + <div class="{{ $style }} order-1 order-1"> 19 + <div class="flex flex-col gap-4"> 20 + {{ template "user/fragments/profileCard" .Card }} 21 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 22 + </div> 23 + </div> 24 + 25 + {{ block "profileContent" . }} {{ end }} 26 + </div> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "profileTabs" }} 31 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 32 + <div class="flex z-60"> 33 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 34 + {{ $tabs := .Card.GetTabs }} 35 + {{ $tabmeta := dict "x" "y" }} 36 + {{ range $item := $tabs }} 37 + {{ $key := index $item 0 }} 38 + {{ $value := index $item 1 }} 39 + {{ $icon := index $item 2 }} 40 + {{ $meta := index $item 3 }} 41 + <a 42 + href="?tab={{ $value }}" 43 + class="relative -mr-px group no-underline hover:no-underline" 44 + hx-boost="true"> 45 + <div 46 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 47 + {{ if eq $.Active $key }} 48 + {{ $activeTabStyles }} 49 + {{ else }} 50 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 + {{ end }} 52 + "> 53 + <span class="flex items-center justify-center"> 54 + {{ i $icon "w-4 h-4 mr-2" }} 55 + {{ $key }} 56 + {{ if $meta }} 57 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + </a> 62 + {{ end }} 63 + </div> 64 + </nav> 65 + {{ end }} 66 + 67 + {{ define "punchcard" }} 68 + {{ $now := now }} 69 + <div> 70 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 71 + PUNCHCARD 72 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 73 + {{ .Total | int64 | commaFmt }} commits 74 + </span> 75 + </p> 76 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 77 + {{ range .Punches }} 78 + {{ $count := .Count }} 79 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 80 + {{ if lt $count 1 }} 81 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 82 + {{ else if lt $count 2 }} 83 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 84 + {{ else if lt $count 4 }} 85 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 86 + {{ else if lt $count 8 }} 87 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 88 + {{ else }} 89 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 90 + {{ end }} 91 + 92 + {{ if .Date.After $now }} 93 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 94 + {{ end }} 95 + <div class="w-full h-full flex justify-center items-center"> 96 + <div 97 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 98 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 99 + </div> 100 + </div> 101 + {{ end }} 102 + </div> 103 + </div> 104 + {{ end }} 105 + 106 + {{ define "layouts/profilebase" }} 107 + {{ template "layouts/base" . }} 108 + {{ end }} 109 +
+19 -28
appview/pages/templates/layouts/repobase.html
··· 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 }}
-80
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 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="/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 - 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 }}
+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 }}
+1
appview/pages/templates/repo/blob.html
··· 78 78 {{ end }} 79 79 </div> 80 80 {{ end }} 81 + {{ template "fragments/multiline-select" }} 81 82 {{ 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}}
-4
appview/pages/templates/repo/empty.html
··· 44 44 {{ end }} 45 45 </main> 46 46 {{ end }} 47 - 48 - {{ define "repoAfter" }} 49 - {{ template "repo/fragments/cloneInstructions" . }} 50 - {{ 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 +
+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 +
+9
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ $formatted := shortRelTimeFmt . }} 3 + {{ $content := printf "%s ago" $formatted }} 4 + {{ if eq $formatted "now" }} 5 + {{ $content = "now" }} 6 + {{ end }} 7 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }} 8 + {{ end }} 9 +
-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 }}
-58
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 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 - 7 - <!-- show user "hats" --> 8 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 - {{ if $isIssueAuthor }} 10 - <span class="before:content-['ยท']"></span> 11 - author 12 - {{ end }} 13 - 14 - <span class="before:content-['ยท']"></span> 15 - <a 16 - href="#{{ .CommentId }}" 17 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 18 - id="{{ .CommentId }}"> 19 - {{ if .Deleted }} 20 - deleted {{ template "repo/fragments/time" .Deleted }} 21 - {{ else if .Edited }} 22 - edited {{ template "repo/fragments/time" .Edited }} 23 - {{ else }} 24 - {{ template "repo/fragments/time" .Created }} 25 - {{ end }} 26 - </a> 27 - 28 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 29 - {{ if and $isCommentOwner (not .Deleted) }} 30 - <button 31 - class="btn px-2 py-1 text-sm" 32 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 33 - hx-swap="outerHTML" 34 - hx-target="#comment-container-{{.CommentId}}" 35 - > 36 - {{ i "pencil" "w-4 h-4" }} 37 - </button> 38 - <button 39 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 40 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 41 - hx-confirm="Are you sure you want to delete your comment?" 42 - hx-swap="outerHTML" 43 - hx-target="#comment-container-{{.CommentId}}" 44 - > 45 - {{ i "trash-2" "w-4 h-4" }} 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - {{ end }} 49 - 50 - </div> 51 - {{ if not .Deleted }} 52 - <div class="prose dark:prose-invert"> 53 - {{ .Body | markdown }} 54 - </div> 55 - {{ end }} 56 - </div> 57 - {{ end }} 58 - {{ 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 "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 -44
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 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 69 - </span> 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 70 71 - <span class="before:content-['ยท']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 74 75 - <span class="before:content-['ยท']"> 76 - {{ $s := "s" }} 77 - {{ if eq .Metadata.CommentCount 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 81 - </span> 82 - </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 }} 83 85 </div> 84 - {{ end }} 85 - </div> 86 - 87 - {{ block "pagination" . }} {{ end }} 88 - 86 + {{ block "pagination" . }} {{ end }} 89 87 {{ end }} 90 88 91 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/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>
+2 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 2 <header class="pb-4"> 3 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 4 + {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 6 </h1> 7 7 </header> ··· 17 17 {{ $icon = "git-merge" }} 18 18 {{ end }} 19 19 20 - {{ $owner := resolve .Pull.OwnerDid }} 21 20 <section class="mt-2"> 22 21 <div class="flex items-center gap-2"> 23 22 <div ··· 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}}
+1 -1
appview/pages/templates/repo/pulls/pull.html
··· 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"
+2 -2
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> ··· 144 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"> 145 145 <div class="flex gap-2 items-center px-6"> 146 146 <div class="flex-grow min-w-0 w-full py-2"> 147 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 148 </div> 149 149 </div> 150 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" }}
+8 -8
appview/pages/templates/repo/tree.html
··· 25 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 26 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 27 27 {{ range .BreadCrumbs }} 28 - <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 28 + <a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 29 29 {{ end }} 30 30 </div> 31 31 <div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 32 32 {{ $stats := .TreeStats }} 33 33 34 - <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 34 + <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span> 35 35 {{ if eq $stats.NumFolders 1 }} 36 36 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 37 37 <span>{{ $stats.NumFolders }} folder</span> ··· 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"> 58 - {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 57 + <div class="col-span-8 md:col-span-4"> 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.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 -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"
+10 -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-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 19 {{ end }} 16 20 17 21 {{ 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> 22 + <section class="rounded flex items-center gap-2"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Spindles are small CI runners. 24 25 </p> 25 - </section> 26 + </section> 26 27 {{ end }} 27 28 28 29 {{ 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" }}
+5 -8
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"> ··· 27 23 hx-boost="true" 28 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 25 {{ i "pencil" "size-4" }} 30 - <span class="hidden md:inline">edit</span> 26 + <span class="hidden md:inline">edit</span> 31 27 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 28 </a> 33 29 <button ··· 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 - <span class="hidden md:inline">delete</span> 37 + <span class="hidden md:inline">delete</span> 42 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 39 </button> 44 40 </div> ··· 77 73 {{ end }} 78 74 </div> 79 75 </div> 80 - <div class="overflow-auto relative"> 76 + <div class="overflow-x-auto overflow-y-hidden relative"> 81 77 {{ if .ShowRendered }} 82 78 <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 79 {{ else }} 84 80 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 81 {{ end }} 86 82 </div> 83 + {{ template "fragments/multiline-select" }} 87 84 </section> 88 85 {{ end }}
+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 }}
-162
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 := resolve $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" $repo.Did }} 82 - {{ with $source }} 83 - {{ $sourceDid := resolve .Did }} 84 - forked 85 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 86 - {{ $sourceDid }}/{{ .Name }} 87 - </a> 88 - to 89 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 90 - {{ else }} 91 - created 92 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 93 - {{ $repo.Name }} 94 - </a> 95 - {{ end }} 96 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 97 - </div> 98 - {{ with $repo }} 99 - {{ template "user/fragments/repoCard" (list $root . true) }} 100 - {{ end }} 101 - {{ end }} 102 - 103 - {{ define "starEvent" }} 104 - {{ $root := index . 0 }} 105 - {{ $star := index . 1 }} 106 - {{ with $star }} 107 - {{ $starrerHandle := resolve .StarredByDid }} 108 - {{ $repoOwnerHandle := resolve .Repo.Did }} 109 - <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"> 110 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 111 - starred 112 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 113 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 114 - </a> 115 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 116 - </div> 117 - {{ with .Repo }} 118 - {{ template "user/fragments/repoCard" (list $root . true) }} 119 - {{ end }} 120 - {{ end }} 121 - {{ end }} 122 - 123 - 124 - {{ define "followEvent" }} 125 - {{ $root := index . 0 }} 126 - {{ $follow := index . 1 }} 127 - {{ $profile := index . 2 }} 128 - {{ $stat := index . 3 }} 129 - 130 - {{ $userHandle := resolve $follow.UserDid }} 131 - {{ $subjectHandle := resolve $follow.SubjectDid }} 132 - <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"> 133 - {{ template "user/fragments/picHandleLink" $userHandle }} 134 - followed 135 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 136 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 137 - </div> 138 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 139 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 140 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 141 - </div> 142 - 143 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 144 - <a href="/{{ $subjectHandle }}"> 145 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 146 - </a> 147 - {{ with $profile }} 148 - {{ with .Description }} 149 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 150 - {{ end }} 151 - {{ end }} 152 - {{ with $stat }} 153 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 154 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 155 - <span id="followers">{{ .Followers }} followers</span> 156 - <span class="select-none after:content-['ยท']"></span> 157 - <span id="following">{{ .Following }} following</span> 158 - </div> 159 - {{ end }} 160 - </div> 161 - </div> 162 - {{ end }}
+2 -4
appview/pages/templates/user/completeSignup.html
··· 29 29 </head> 30 30 <body class="flex items-center justify-center min-h-screen"> 31 31 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 35 - tangled 32 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 36 34 </h1> 37 35 <h2 class="text-center text-xl italic dark:text-white"> 38 36 tightly-knit social coding.
+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>
+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 }}
+17 -16
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"> ··· 8 8 </div> 9 9 <div class="col-span-2"> 10 10 <div class="flex items-center flex-row flex-nowrap gap-2"> 11 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 + <p title="{{ $userIdent }}" 12 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 - {{ didOrHandle .UserDid .UserHandle }} 13 + {{ $userIdent }} 14 14 </p> 15 - <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 16 </div> 17 17 18 18 <div class="md:hidden"> 19 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 19 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 20 20 </div> 21 21 </div> 22 22 <div class="col-span-3 md:col-span-full"> ··· 29 29 {{ end }} 30 30 31 31 <div class="hidden md:block"> 32 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 32 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 33 33 </div> 34 34 35 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 42 42 {{ if .IncludeBluesky }} 43 43 <div class="flex items-center gap-2"> 44 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 45 - <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> 46 46 </div> 47 47 {{ end }} 48 48 {{ range $link := .Links }} ··· 84 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 85 </div> 86 86 </div> 87 - </div> 88 87 {{ end }} 89 88 90 89 {{ define "followerFollowing" }} 91 - {{ $followers := index . 0 }} 92 - {{ $following := index . 1 }} 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">{{ $followers }} followers</span> 96 - <span class="select-none after:content-['ยท']"></span> 97 - <span id="following">{{ $following }} following</span> 98 - </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 }} 99 100 {{ end }} 100 101
+6 -7
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 gap-2 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 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 8 <div class="font-medium dark:text-white flex items-center"> 9 9 {{ if .Source }} 10 10 {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} ··· 14 14 15 15 {{ $repoOwner := resolve .Did }} 16 16 {{- if $fullName -}} 17 - <a href="/{{ $repoOwner }}/{{ .Name }}">{{ $repoOwner }}/{{ .Name }}</a> 17 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 18 {{- else -}} 19 - <a href="/{{ $repoOwner }}/{{ .Name }}">{{ .Name }}</a> 19 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 20 {{- end -}} 21 21 </div> 22 22 {{ with .Description }} 23 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 24 - {{ . }} 23 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 24 + {{ . | description }} 25 25 </div> 26 26 {{ end }} 27 27 ··· 36 36 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 37 {{ with .Language }} 38 38 <div class="flex gap-2 items-center text-sm"> 39 - <div class="size-2 rounded-full" 40 - style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 39 + {{ template "repo/fragments/languageBall" . }} 41 40 <span>{{ . }}</span> 42 41 </div> 43 42 {{ end }}
+3 -2
appview/pages/templates/user/login.html
··· 13 13 </head> 14 14 <body class="flex items-center justify-center min-h-screen"> 15 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 17 - tangled 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 18 </h1> 19 19 <h2 class="text-center text-xl italic dark:text-white"> 20 20 tightly-knit social coding. ··· 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 +
-318
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" .RepoEvents }} {{ end }} 54 - {{ block "issueEvents" .IssueEvents }} {{ end }} 55 - {{ block "pullEvents" .PullEvents }} {{ 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 - {{ if gt (len .) 0 }} 70 - <details> 71 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 72 - <div class="flex flex-wrap items-center gap-2"> 73 - {{ i "book-plus" "w-4 h-4" }} 74 - created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 75 - </div> 76 - </summary> 77 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 78 - {{ range . }} 79 - <div class="flex flex-wrap items-center gap-2"> 80 - <span class="text-gray-500 dark:text-gray-400"> 81 - {{ if .Source }} 82 - {{ i "git-fork" "w-4 h-4" }} 83 - {{ else }} 84 - {{ i "book-plus" "w-4 h-4" }} 85 - {{ end }} 86 - </span> 87 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 88 - {{- .Repo.Name -}} 89 - </a> 90 - </div> 91 - {{ end }} 92 - </div> 93 - </details> 94 - {{ end }} 95 - {{ end }} 96 - 97 - {{ define "issueEvents" }} 98 - {{ $items := .Items }} 99 - {{ $stats := .Stats }} 100 - 101 - {{ if gt (len $items) 0 }} 102 - <details> 103 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 104 - <div class="flex flex-wrap items-center gap-2"> 105 - {{ i "circle-dot" "w-4 h-4" }} 106 - 107 - <div> 108 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 109 - </div> 110 - 111 - {{ if gt $stats.Open 0 }} 112 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 113 - {{$stats.Open}} open 114 - </span> 115 - {{ end }} 116 - 117 - {{ if gt $stats.Closed 0 }} 118 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 119 - {{$stats.Closed}} closed 120 - </span> 121 - {{ end }} 122 - 123 - </div> 124 - </summary> 125 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 126 - {{ range $items }} 127 - {{ $repoOwner := resolve .Metadata.Repo.Did }} 128 - {{ $repoName := .Metadata.Repo.Name }} 129 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 130 - 131 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 132 - {{ if .Open }} 133 - <span class="text-green-600 dark:text-green-500"> 134 - {{ i "circle-dot" "w-4 h-4" }} 135 - </span> 136 - {{ else }} 137 - <span class="text-gray-500 dark:text-gray-400"> 138 - {{ i "ban" "w-4 h-4" }} 139 - </span> 140 - {{ end }} 141 - <div class="flex-none min-w-8 text-right"> 142 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 143 - </div> 144 - <div class="break-words max-w-full"> 145 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 146 - {{ .Title -}} 147 - </a> 148 - on 149 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 150 - {{$repoUrl}} 151 - </a> 152 - </div> 153 - </div> 154 - {{ end }} 155 - </div> 156 - </details> 157 - {{ end }} 158 - {{ end }} 159 - 160 - {{ define "pullEvents" }} 161 - {{ $items := .Items }} 162 - {{ $stats := .Stats }} 163 - {{ if gt (len $items) 0 }} 164 - <details> 165 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 166 - <div class="flex flex-wrap items-center gap-2"> 167 - {{ i "git-pull-request" "w-4 h-4" }} 168 - 169 - <div> 170 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 171 - </div> 172 - 173 - {{ if gt $stats.Open 0 }} 174 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 175 - {{$stats.Open}} open 176 - </span> 177 - {{ end }} 178 - 179 - {{ if gt $stats.Merged 0 }} 180 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 181 - {{$stats.Merged}} merged 182 - </span> 183 - {{ end }} 184 - 185 - 186 - {{ if gt $stats.Closed 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 188 - {{$stats.Closed}} closed 189 - </span> 190 - {{ end }} 191 - 192 - </div> 193 - </summary> 194 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 195 - {{ range $items }} 196 - {{ $repoOwner := resolve .Repo.Did }} 197 - {{ $repoName := .Repo.Name }} 198 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 199 - 200 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 201 - {{ if .State.IsOpen }} 202 - <span class="text-green-600 dark:text-green-500"> 203 - {{ i "git-pull-request" "w-4 h-4" }} 204 - </span> 205 - {{ else if .State.IsMerged }} 206 - <span class="text-purple-600 dark:text-purple-500"> 207 - {{ i "git-merge" "w-4 h-4" }} 208 - </span> 209 - {{ else }} 210 - <span class="text-gray-600 dark:text-gray-300"> 211 - {{ i "git-pull-request-closed" "w-4 h-4" }} 212 - </span> 213 - {{ end }} 214 - <div class="flex-none min-w-8 text-right"> 215 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 216 - </div> 217 - <div class="break-words max-w-full"> 218 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 219 - {{ .Title -}} 220 - </a> 221 - on 222 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 223 - {{$repoUrl}} 224 - </a> 225 - </div> 226 - </div> 227 - {{ end }} 228 - </div> 229 - </details> 230 - {{ end }} 231 - {{ end }} 232 - 233 - {{ define "ownRepos" }} 234 - <div> 235 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 236 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 237 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 238 - <span>PINNED REPOS</span> 239 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 240 - view all {{ i "chevron-right" "w-4 h-4" }} 241 - </span> 242 - </a> 243 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 244 - <button 245 - hx-get="profile/edit-pins" 246 - hx-target="#all-repos" 247 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 248 - {{ i "pencil" "w-3 h-3" }} 249 - edit 250 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 251 - </button> 252 - {{ end }} 253 - </div> 254 - <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 255 - {{ range .Repos }} 256 - {{ template "user/fragments/repoCard" (list $ . false) }} 257 - {{ else }} 258 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 259 - {{ end }} 260 - </div> 261 - </div> 262 - {{ end }} 263 - 264 - {{ define "collaboratingRepos" }} 265 - {{ if gt (len .CollaboratingRepos) 0 }} 266 - <div> 267 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 268 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 269 - {{ range .CollaboratingRepos }} 270 - {{ template "user/fragments/repoCard" (list $ . true) }} 271 - {{ else }} 272 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 273 - {{ end }} 274 - </div> 275 - </div> 276 - {{ end }} 277 - {{ end }} 278 - 279 - {{ define "punchcard" }} 280 - {{ $now := now }} 281 - <div> 282 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 283 - PUNCHCARD 284 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 285 - {{ .Total | int64 | commaFmt }} commits 286 - </span> 287 - </p> 288 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 289 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 290 - {{ range .Punches }} 291 - {{ $count := .Count }} 292 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 293 - {{ if lt $count 1 }} 294 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 295 - {{ else if lt $count 2 }} 296 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 297 - {{ else if lt $count 4 }} 298 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 299 - {{ else if lt $count 8 }} 300 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 301 - {{ else }} 302 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 303 - {{ end }} 304 - 305 - {{ if .Date.After $now }} 306 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 307 - {{ end }} 308 - <div class="w-full h-full flex justify-center items-center"> 309 - <div 310 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 311 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 312 - </div> 313 - </div> 314 - {{ end }} 315 - </div> 316 - </div> 317 - </div> 318 - {{ 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 }}
+3 -1
appview/pages/templates/user/signup.html
··· 13 13 </head> 14 14 <body class="flex items-center justify-center min-h-screen"> 15 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 17 19 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 20 <form 19 21 class="mt-4 max-w-sm mx-auto"
+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 }}
+34 -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(), ··· 129 129 log.Println("failed to enqueue posthog event:", err) 130 130 } 131 131 } 132 + 133 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 + err := n.client.Enqueue(posthog.Capture{ 135 + DistinctId: did, 136 + Event: "delete_string", 137 + Properties: posthog.Properties{"rkey": rkey}, 138 + }) 139 + if err != nil { 140 + log.Println("failed to enqueue posthog event:", err) 141 + } 142 + } 143 + 144 + func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) { 145 + err := n.client.Enqueue(posthog.Capture{ 146 + DistinctId: string.Did.String(), 147 + Event: "edit_string", 148 + Properties: posthog.Properties{"rkey": string.Rkey}, 149 + }) 150 + if err != nil { 151 + log.Println("failed to enqueue posthog event:", err) 152 + } 153 + } 154 + 155 + func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) { 156 + err := n.client.Enqueue(posthog.Capture{ 157 + DistinctId: string.Did.String(), 158 + Event: "create_string", 159 + Properties: posthog.Properties{"rkey": string.Rkey}, 160 + }) 161 + if err != nil { 162 + log.Println("failed to enqueue posthog event:", err) 163 + } 164 + }
+389 -209
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 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 155 resubmitResult := pages.Unknown 156 156 if user != nil && user.Did == pull.OwnerDid { 157 - resubmitResult = s.resubmitCheck(f, pull, stack) 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 158 } 159 159 160 160 repoInfo := f.RepoInfo(user) ··· 215 215 }) 216 216 } 217 217 218 - 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 { 219 219 if pull.State == db.PullMerged { 220 220 return types.MergeCheckResponse{} 221 221 } 222 222 223 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 224 - if err != nil { 225 - log.Printf("failed to get registration key: %v", err) 226 - return types.MergeCheckResponse{ 227 - Error: "failed to check merge status: this knot is unregistered", 228 - } 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 229 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 230 228 231 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 232 - if err != nil { 233 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 234 - return types.MergeCheckResponse{ 235 - Error: "failed to check merge status", 236 - } 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 237 231 } 238 232 239 233 patch := pull.LatestPatch() ··· 246 240 patch = mergeable.CombinedPatch() 247 241 } 248 242 249 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 250 - if err != nil { 251 - 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) 252 255 return types.MergeCheckResponse{ 253 - Error: "failed to check merge status", 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 254 257 } 255 258 } 256 - switch resp.StatusCode { 257 - case 404: 258 - return types.MergeCheckResponse{ 259 - Error: "failed to check merge status: this knot does not support PRs", 260 - } 261 - case 400: 262 - return types.MergeCheckResponse{ 263 - 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, 264 266 } 265 267 } 266 268 267 - respBody, err := io.ReadAll(resp.Body) 268 - if err != nil { 269 - log.Println("failed to read merge check response body") 270 - return types.MergeCheckResponse{ 271 - Error: "failed to check merge status: knot is not speaking the right language", 272 - } 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 273 272 } 274 - defer resp.Body.Close() 275 273 276 - var mergeCheckResponse types.MergeCheckResponse 277 - err = json.Unmarshal(respBody, &mergeCheckResponse) 278 - if err != nil { 279 - log.Println("failed to unmarshal merge check response", err) 280 - return types.MergeCheckResponse{ 281 - Error: "failed to check merge status: knot is not speaking the right language", 282 - } 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 276 + } 277 + 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 283 280 } 284 281 285 - return mergeCheckResponse 282 + return result 286 283 } 287 284 288 - 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 { 289 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 290 287 return pages.Unknown 291 288 } ··· 307 304 // pulls within the same repo 308 305 knot = f.Knot 309 306 ownerDid = f.OwnerDid() 310 - repoName = f.RepoName 307 + repoName = f.Name 311 308 } 312 309 313 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 314 - if err != nil { 315 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 316 - 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, 317 317 } 318 318 319 - 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) 320 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 + } 321 326 log.Println("failed to reach knotserver", err) 322 327 return pages.Unknown 323 328 } 324 329 330 + targetBranch := branchResp 331 + 325 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 326 333 327 334 if pull.IsStacked() && stack != nil { ··· 329 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 330 337 } 331 338 332 - if latestSourceRev != result.Branch.Hash { 339 + if latestSourceRev != targetBranch.Hash { 333 340 return pages.ShouldResubmit 334 341 } 335 342 ··· 483 490 484 491 pulls, err := db.GetPulls( 485 492 s.db, 486 - db.FilterEq("repo_at", f.RepoAt), 493 + db.FilterEq("repo_at", f.RepoAt()), 487 494 db.FilterEq("state", state), 488 495 ) 489 496 if err != nil { ··· 608 615 defer tx.Rollback() 609 616 610 617 createdAt := time.Now().Format(time.RFC3339) 611 - ownerDid := user.Did 612 618 613 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 619 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 614 620 if err != nil { 615 621 log.Println("failed to get pull at", err) 616 622 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 617 623 return 618 624 } 619 625 620 - atUri := f.RepoAt.String() 621 626 client, err := s.oauth.AuthorizedClient(r) 622 627 if err != nil { 623 628 log.Println("failed to get authorized client", err) ··· 630 635 Rkey: tid.TID(), 631 636 Record: &lexutil.LexiconTypeDecoder{ 632 637 Val: &tangled.RepoPullComment{ 633 - Repo: &atUri, 634 638 Pull: string(pullAt), 635 - Owner: &ownerDid, 636 639 Body: body, 637 640 CreatedAt: createdAt, 638 641 }, ··· 646 649 647 650 comment := &db.PullComment{ 648 651 OwnerDid: user.Did, 649 - RepoAt: f.RepoAt.String(), 652 + RepoAt: f.RepoAt().String(), 650 653 PullId: pull.PullId, 651 654 Body: body, 652 655 CommentAt: atResp.Uri, ··· 685 688 686 689 switch r.Method { 687 690 case http.MethodGet: 688 - 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) 689 702 if err != nil { 690 - log.Printf("failed to create unsigned client for %s", f.Knot) 691 - 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) 692 709 return 693 710 } 694 711 695 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 696 - if err != nil { 697 - 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) 698 716 return 699 717 } 700 718 ··· 740 758 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 741 759 return 742 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 + } 743 766 } 744 767 745 768 // Validate we have at least one valid PR creation method ··· 754 777 return 755 778 } 756 779 757 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 758 - if err != nil { 759 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 760 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 761 - 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 + }, 762 807 } 763 808 764 - caps, err := us.Capabilities() 765 - if err != nil { 766 - log.Println("error fetching knot caps", f.Knot, err) 767 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 768 - return 769 - } 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 + // } 770 815 771 816 if !caps.PullRequests.FormatPatch { 772 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 808 853 sourceBranch string, 809 854 isStacked bool, 810 855 ) { 811 - // Generate a patch using /compare 812 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 813 - if err != nil { 814 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 815 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 816 - 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, 817 863 } 818 864 819 - 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) 820 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 + } 821 873 log.Println("failed to compare", err) 822 874 s.pages.Notice(w, "pull", err.Error()) 823 875 return 824 876 } 825 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 + 826 885 sourceRev := comparison.Rev2 827 886 patch := comparison.Patch 828 887 ··· 852 911 } 853 912 854 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) { 855 - 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) 856 918 if errors.Is(err, sql.ErrNoRows) { 857 919 s.pages.Notice(w, "pull", "No such fork.") 858 920 return ··· 862 924 return 863 925 } 864 926 865 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 866 - if err != nil { 867 - log.Println("failed to fetch registration key:", err) 868 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 869 - return 870 - } 871 - 872 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 873 - if err != nil { 874 - log.Println("failed to create signed client:", err) 875 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 876 - return 877 - } 878 - 879 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 880 - if err != nil { 881 - log.Println("failed to create unsigned client:", err) 882 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 883 - return 884 - } 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 + ) 885 933 886 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 887 - if err != nil { 888 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 889 - 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()) 890 945 return 891 946 } 892 947 893 - switch resp.StatusCode { 894 - case 404: 895 - case 400: 896 - 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) 897 954 return 898 955 } 899 956 ··· 903 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 904 961 // targetBranch: main (on repo-1) 905 962 // sourceBranch: feature-1 (on repo-fork) 906 - 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) 907 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 + } 908 980 log.Println("failed to compare across branches", err) 909 981 s.pages.Notice(w, "pull", err.Error()) 910 982 return 911 983 } 912 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.") 989 + return 990 + } 991 + 913 992 sourceRev := comparison.Rev2 914 993 patch := comparison.Patch 915 994 ··· 918 997 return 919 998 } 920 999 921 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 922 - if err != nil { 923 - log.Println("failed to parse fork AT URI", err) 924 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 925 - return 926 - } 1000 + forkAtUri := fork.RepoAt() 1001 + forkAtUriStr := forkAtUri.String() 927 1002 928 1003 pullSource := &db.PullSource{ 929 1004 Branch: sourceBranch, ··· 931 1006 } 932 1007 recordPullSource := &tangled.RepoPull_Source{ 933 1008 Branch: sourceBranch, 934 - Repo: &fork.AtUri, 1009 + Repo: &forkAtUriStr, 935 1010 Sha: sourceRev, 936 1011 } 937 1012 ··· 1007 1082 Body: body, 1008 1083 TargetBranch: targetBranch, 1009 1084 OwnerDid: user.Did, 1010 - RepoAt: f.RepoAt, 1085 + RepoAt: f.RepoAt(), 1011 1086 Rkey: rkey, 1012 1087 Submissions: []*db.PullSubmission{ 1013 1088 &initialSubmission, ··· 1020 1095 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1021 1096 return 1022 1097 } 1023 - pullId, err := db.NextPullId(tx, f.RepoAt) 1098 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1024 1099 if err != nil { 1025 1100 log.Println("failed to get pull id", err) 1026 1101 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1033 1108 Rkey: rkey, 1034 1109 Record: &lexutil.LexiconTypeDecoder{ 1035 1110 Val: &tangled.RepoPull{ 1036 - Title: title, 1037 - PullId: int64(pullId), 1038 - TargetRepo: string(f.RepoAt), 1039 - TargetBranch: targetBranch, 1040 - Patch: patch, 1041 - 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, 1042 1118 }, 1043 1119 }, 1044 1120 }) ··· 1206 1282 return 1207 1283 } 1208 1284 1209 - 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) 1210 1296 if err != nil { 1211 - log.Printf("failed to create unsigned client for %s", f.Knot) 1212 - 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) 1213 1303 return 1214 1304 } 1215 1305 1216 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1217 - if err != nil { 1218 - 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) 1219 1310 return 1220 1311 } 1221 1312 ··· 1269 1360 } 1270 1361 1271 1362 forkVal := r.URL.Query().Get("fork") 1272 - 1363 + repoString := strings.SplitN(forkVal, "/", 2) 1364 + forkOwnerDid := repoString[0] 1365 + forkName := repoString[1] 1273 1366 // fork repo 1274 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1367 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1275 1368 if err != nil { 1276 1369 log.Println("failed to get repo", user.Did, forkVal) 1277 1370 return 1278 1371 } 1279 1372 1280 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1281 - if err != nil { 1282 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1283 - s.pages.Error503(w) 1284 - 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, 1285 1380 } 1286 1381 1287 - 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) 1288 1384 if err != nil { 1289 - 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) 1290 1391 return 1291 1392 } 1292 1393 1293 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1294 - if err != nil { 1295 - 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) 1296 1398 s.pages.Error503(w) 1297 1399 return 1298 1400 } 1299 1401 1300 - 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) 1301 1413 if err != nil { 1302 - 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) 1420 + return 1421 + } 1422 + 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) 1303 1428 return 1304 1429 } 1305 1430 1306 - sourceBranches := sourceResult.Branches 1307 - sort.Slice(sourceBranches, func(i int, j int) bool { 1308 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 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) 1309 1433 }) 1310 1434 1311 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1312 1436 RepoInfo: f.RepoInfo(user), 1313 - SourceBranches: sourceBranches, 1314 - TargetBranches: targetResult.Branches, 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1315 1439 }) 1316 1440 } 1317 1441 ··· 1406 1530 return 1407 1531 } 1408 1532 1409 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1410 - if err != nil { 1411 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1412 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1413 - 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, 1414 1540 } 1415 1541 1416 - 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) 1417 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 + } 1418 1550 log.Printf("compare request failed: %s", err) 1419 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.") 1420 1559 return 1421 1560 } 1422 1561 ··· 1456 1595 } 1457 1596 1458 1597 // extract patch by performing compare 1459 - 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) 1460 1605 if err != nil { 1461 - 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) 1462 1612 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1463 1613 return 1464 1614 } 1465 1615 1466 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1467 - if err != nil { 1468 - 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) 1469 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1470 1620 return 1471 1621 } 1472 1622 1473 1623 // update the hidden tracking branch to latest 1474 - 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 + ) 1475 1630 if err != nil { 1476 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1477 - 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) 1478 1632 return 1479 1633 } 1480 1634 1481 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1482 - if err != nil || resp.StatusCode != http.StatusNoContent { 1483 - log.Printf("failed to update tracking branch: %s", err) 1484 - 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()) 1485 1646 return 1486 1647 } 1487 - 1488 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1489 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1490 - if err != nil { 1491 - log.Printf("failed to compare branches: %s", err) 1492 - 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.") 1493 1651 return 1494 1652 } 1653 + 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1495 1656 1496 1657 sourceRev := comparison.Rev2 1497 1658 patch := comparison.Patch ··· 1595 1756 SwapRecord: ex.Cid, 1596 1757 Record: &lexutil.LexiconTypeDecoder{ 1597 1758 Val: &tangled.RepoPull{ 1598 - Title: pull.Title, 1599 - PullId: int64(pull.PullId), 1600 - TargetRepo: string(f.RepoAt), 1601 - TargetBranch: pull.TargetBranch, 1602 - Patch: patch, // new patch 1603 - 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, 1604 1766 }, 1605 1767 }, 1606 1768 }) ··· 1713 1875 1714 1876 // deleted pulls are marked as deleted in the DB 1715 1877 for _, p := range deletions { 1878 + // do not do delete already merged PRs 1879 + if p.State == db.PullMerged { 1880 + continue 1881 + } 1882 + 1716 1883 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1717 1884 if err != nil { 1718 1885 log.Println("failed to delete pull", err, p.PullId) ··· 1753 1920 op, _ := origById[id] 1754 1921 np, _ := newById[id] 1755 1922 1923 + // do not update already merged PRs 1924 + if op.State == db.PullMerged { 1925 + continue 1926 + } 1927 + 1756 1928 submission := np.Submissions[np.LastRoundNumber()] 1757 1929 1758 1930 // resubmit the old pull ··· 1897 2069 1898 2070 patch := pullsToMerge.CombinedPatch() 1899 2071 1900 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1901 - if err != nil { 1902 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1903 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1904 - return 1905 - } 1906 - 1907 2072 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1908 2073 if err != nil { 1909 2074 log.Printf("resolving identity: %s", err) ··· 1916 2081 log.Printf("failed to get primary email: %s", err) 1917 2082 } 1918 2083 1919 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1920 - if err != nil { 1921 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1922 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1923 - 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, 1924 2092 } 1925 2093 1926 - // Merge the pull request 1927 - 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 + ) 1928 2108 if err != nil { 1929 - log.Printf("failed to merge pull request: %s", err) 2109 + log.Printf("failed to connect to knot server: %v", err) 1930 2110 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1931 2111 return 1932 2112 } 1933 2113 1934 - if resp.StatusCode != http.StatusOK { 1935 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1936 - 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()) 1937 2117 return 1938 2118 } 1939 2119 ··· 1946 2126 defer tx.Rollback() 1947 2127 1948 2128 for _, p := range pullsToMerge { 1949 - err := db.MergePull(tx, f.RepoAt, p.PullId) 2129 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 1950 2130 if err != nil { 1951 2131 log.Printf("failed to update pull request status in database: %s", err) 1952 2132 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1962 2142 return 1963 2143 } 1964 2144 1965 - 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)) 1966 2146 } 1967 2147 1968 2148 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2014 2194 2015 2195 for _, p := range pullsToClose { 2016 2196 // Close the pull in the database 2017 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2197 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2018 2198 if err != nil { 2019 2199 log.Println("failed to close pull", err) 2020 2200 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2082 2262 2083 2263 for _, p := range pullsToReopen { 2084 2264 // Close the pull in the database 2085 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2265 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2086 2266 if err != nil { 2087 2267 log.Println("failed to close pull", err) 2088 2268 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2134 2314 Body: body, 2135 2315 TargetBranch: targetBranch, 2136 2316 OwnerDid: user.Did, 2137 - RepoAt: f.RepoAt, 2317 + RepoAt: f.RepoAt(), 2138 2318 Rkey: rkey, 2139 2319 Submissions: []*db.PullSubmission{ 2140 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 + }
+198 -101
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 + "net/url" 8 9 "slices" 9 10 "sort" 10 11 "strings" 12 + "sync" 13 + "time" 11 14 15 + "context" 16 + "encoding/json" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-git/go-git/v5/plumbing" 20 + "tangled.sh/tangled.sh/core/api/tangled" 12 21 "tangled.sh/tangled.sh/core/appview/commitverify" 13 22 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 23 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 17 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 - "tangled.sh/tangled.sh/core/knotclient" 26 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 19 27 "tangled.sh/tangled.sh/core/types" 20 28 21 29 "github.com/go-chi/chi/v5" ··· 24 32 25 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 34 ref := chi.URLParam(r, "ref") 35 + ref, _ = url.PathUnescape(ref) 36 + 27 37 f, err := rp.repoResolver.Resolve(r) 28 38 if err != nil { 29 39 log.Println("failed to fully resolve repo", err) 30 40 return 31 41 } 32 42 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 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 38 50 } 39 51 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 - if err != nil { 52 + user := rp.oauth.GetUser(r) 53 + repoInfo := f.RepoInfo(user) 54 + 55 + // Build index response from multiple XRPC calls 56 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 + log.Println("failed to call XRPC repo.index", err) 60 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 + LoggedInUser: user, 62 + NeedsKnotUpgrade: true, 63 + RepoInfo: repoInfo, 64 + }) 65 + return 66 + } 67 + 42 68 rp.pages.Error503(w) 43 - log.Println("failed to reach knotserver", err) 69 + log.Println("failed to build index response", err) 44 70 return 45 71 } 46 72 ··· 101 127 log.Println(err) 102 128 } 103 129 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 - } 127 - 128 130 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 131 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 130 132 if err != nil { 131 133 log.Printf("failed to compute language percentages: %s", err) 132 134 // non-fatal ··· 143 145 } 144 146 145 147 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, 148 + LoggedInUser: user, 149 + RepoInfo: repoInfo, 150 + TagMap: tagMap, 151 + RepoIndexResponse: *result, 152 + CommitsTrunc: commitsTrunc, 153 + TagsTrunc: tagsTrunc, 154 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 153 155 BranchesTrunc: branchesTrunc, 154 156 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 155 157 VerifiedCommits: vc, ··· 159 161 } 160 162 161 163 func (rp *Repo) getLanguageInfo( 164 + ctx context.Context, 162 165 f *reporesolver.ResolvedRepo, 163 - signedClient *knotclient.SignedClient, 166 + xrpcc *indigoxrpc.Client, 167 + currentRef string, 164 168 isDefaultRef bool, 165 169 ) ([]types.RepoLanguageDetails, error) { 166 170 // first attempt to fetch from db 167 171 langs, err := db.GetRepoLanguages( 168 172 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 173 + db.FilterEq("repo_at", f.RepoAt()), 174 + db.FilterEq("ref", currentRef), 171 175 ) 172 176 173 177 if err != nil || langs == nil { 174 - // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 178 + // non-fatal, fetch langs from ks via XRPC 179 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 180 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 176 181 if err != nil { 182 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 + log.Println("failed to call XRPC repo.languages", xrpcerr) 184 + return nil, xrpcerr 185 + } 177 186 return nil, err 178 187 } 179 - if ls == nil { 188 + 189 + if ls == nil || ls.Languages == nil { 180 190 return nil, nil 181 191 } 182 192 183 - for l, s := range ls.Languages { 193 + for _, lang := range ls.Languages { 184 194 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 195 + RepoAt: f.RepoAt(), 196 + Ref: currentRef, 187 197 IsDefaultRef: isDefaultRef, 188 - Language: l, 189 - Bytes: s, 198 + Language: lang.Name, 199 + Bytes: lang.Size, 190 200 }) 191 201 } 192 202 ··· 230 240 return languageStats, nil 231 241 } 232 242 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 243 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 244 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 245 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 246 + 247 + // first get branches to determine the ref if not specified 248 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 249 + if err != nil { 250 + return nil, fmt.Errorf("failed to call repoBranches: %w", err) 242 251 } 243 252 244 - forkInfo := types.ForkInfo{ 245 - IsFork: repoInfo.Source != nil, 246 - Status: types.UpToDate, 253 + var branchesResp types.RepoBranchesResponse 254 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 255 + return nil, fmt.Errorf("failed to unmarshal branches response: %w", err) 247 256 } 248 257 249 - if !forkInfo.IsFork { 250 - forkInfo.IsFork = false 251 - return &forkInfo, nil 258 + // if no ref specified, use default branch or first available 259 + if ref == "" { 260 + for _, branch := range branchesResp.Branches { 261 + if branch.IsDefault { 262 + ref = branch.Name 263 + break 264 + } 265 + } 252 266 } 253 267 254 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 255 - if err != nil { 256 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 257 - return nil, err 268 + // if ref is still empty, this means the default branch is not set 269 + if ref == "" { 270 + return &types.RepoIndexResponse{ 271 + IsEmpty: true, 272 + Branches: branchesResp.Branches, 273 + }, nil 258 274 } 259 275 260 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 261 - if err != nil { 262 - log.Println("failed to reach knotserver", err) 263 - return nil, err 264 - } 276 + // now run the remaining queries in parallel 277 + var wg sync.WaitGroup 278 + var errs error 279 + 280 + var ( 281 + tagsResp types.RepoTagsResponse 282 + treeResp *tangled.RepoTree_Output 283 + logResp types.RepoLogResponse 284 + readmeContent string 285 + readmeFileName string 286 + ) 287 + 288 + // tags 289 + wg.Add(1) 290 + go func() { 291 + defer wg.Done() 292 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 293 + if err != nil { 294 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 295 + return 296 + } 297 + 298 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 299 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 300 + } 301 + }() 302 + 303 + // tree/files 304 + wg.Add(1) 305 + go func() { 306 + defer wg.Done() 307 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 308 + if err != nil { 309 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 310 + return 311 + } 312 + treeResp = resp 313 + }() 314 + 315 + // commits 316 + wg.Add(1) 317 + go func() { 318 + defer wg.Done() 319 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 320 + if err != nil { 321 + errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 322 + return 323 + } 324 + 325 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 326 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 327 + } 328 + }() 329 + 330 + // readme content 331 + wg.Add(1) 332 + go func() { 333 + defer wg.Done() 334 + for _, filename := range markup.ReadmeFilenames { 335 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 336 + if err != nil { 337 + continue 338 + } 265 339 266 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 268 - }) { 269 - forkInfo.Status = types.MissingBranch 270 - return &forkInfo, nil 271 - } 340 + if blobResp == nil { 341 + continue 342 + } 272 343 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 274 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 - log.Printf("failed to update tracking branch: %s", err) 276 - return nil, err 277 - } 344 + readmeContent = blobResp.Content 345 + readmeFileName = filename 346 + break 347 + } 348 + }() 278 349 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 350 + wg.Wait() 280 351 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 352 + if errs != nil { 353 + return nil, errs 286 354 } 287 355 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 356 + var files []types.NiceTree 357 + if treeResp != nil && treeResp.Files != nil { 358 + for _, file := range treeResp.Files { 359 + niceFile := types.NiceTree{ 360 + IsFile: file.Is_file, 361 + IsSubtree: file.Is_subtree, 362 + Name: file.Name, 363 + Mode: file.Mode, 364 + Size: file.Size, 365 + } 366 + if file.Last_commit != nil { 367 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 368 + niceFile.LastCommit = &types.LastCommitInfo{ 369 + Hash: plumbing.NewHash(file.Last_commit.Hash), 370 + Message: file.Last_commit.Message, 371 + When: when, 372 + } 373 + } 374 + files = append(files, niceFile) 375 + } 291 376 } 292 377 293 - forkInfo.Status = status.Status 294 - return &forkInfo, nil 378 + result := &types.RepoIndexResponse{ 379 + IsEmpty: false, 380 + Ref: ref, 381 + Readme: readmeContent, 382 + ReadmeFileName: readmeFileName, 383 + Commits: logResp.Commits, 384 + Description: logResp.Description, 385 + Files: files, 386 + Branches: branchesResp.Branches, 387 + Tags: tagsResp.Tags, 388 + TotalCommits: logResp.Total, 389 + } 390 + 391 + return result, nil 295 392 }
+618 -369
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 23 "tangled.sh/tangled.sh/core/api/tangled" 21 24 "tangled.sh/tangled.sh/core/appview/commitverify" 22 25 "tangled.sh/tangled.sh/core/appview/config" ··· 26 29 "tangled.sh/tangled.sh/core/appview/pages" 27 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 31 "tangled.sh/tangled.sh/core/appview/reporesolver" 32 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 33 "tangled.sh/tangled.sh/core/eventconsumer" 30 34 "tangled.sh/tangled.sh/core/idresolver" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 35 "tangled.sh/tangled.sh/core/patchutil" 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 35 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 36 40 37 41 securejoin "github.com/cyphar/filepath-securejoin" 38 42 "github.com/go-chi/chi/v5" 39 43 "github.com/go-git/go-git/v5/plumbing" 40 44 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 45 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 46 ) 45 47 46 48 type Repo struct { ··· 54 56 enforcer *rbac.Enforcer 55 57 notifier notify.Notifier 56 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 57 60 } 58 61 59 62 func New( ··· 82 85 } 83 86 84 87 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 85 - refParam := chi.URLParam(r, "ref") 88 + ref := chi.URLParam(r, "ref") 89 + ref, _ = url.PathUnescape(ref) 90 + 86 91 f, err := rp.repoResolver.Resolve(r) 87 92 if err != nil { 88 93 log.Println("failed to get repo and knot", err) 89 94 return 90 95 } 91 96 92 - var uri string 93 - if rp.config.Core.Dev { 94 - uri = "http" 95 - } else { 96 - uri = "https" 97 + scheme := "http" 98 + if !rp.config.Core.Dev { 99 + scheme = "https" 100 + } 101 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 102 + xrpcc := &indigoxrpc.Client{ 103 + Host: host, 97 104 } 98 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam)) 99 105 100 - http.Redirect(w, r, url, http.StatusFound) 106 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 107 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 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 + 114 + // Set headers for file download, just pass along whatever the knot specifies 115 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 116 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 117 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 118 + w.Header().Set("Content-Type", "application/gzip") 119 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 120 + 121 + // Write the archive data directly 122 + w.Write(archiveBytes) 101 123 } 102 124 103 125 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 116 138 } 117 139 118 140 ref := chi.URLParam(r, "ref") 141 + ref, _ = url.PathUnescape(ref) 119 142 120 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 121 - if err != nil { 122 - log.Println("failed to create unsigned client", err) 143 + scheme := "http" 144 + if !rp.config.Core.Dev { 145 + scheme = "https" 146 + } 147 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 148 + xrpcc := &indigoxrpc.Client{ 149 + Host: host, 150 + } 151 + 152 + limit := int64(60) 153 + cursor := "" 154 + if page > 1 { 155 + // Convert page number to cursor (offset) 156 + offset := (page - 1) * int(limit) 157 + cursor = strconv.Itoa(offset) 158 + } 159 + 160 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 161 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 162 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 163 + log.Println("failed to call XRPC repo.log", xrpcerr) 164 + rp.pages.Error503(w) 123 165 return 124 166 } 125 167 126 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 127 - if err != nil { 128 - log.Println("failed to reach knotserver", err) 168 + var xrpcResp types.RepoLogResponse 169 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 170 + log.Println("failed to decode XRPC response", err) 171 + rp.pages.Error503(w) 129 172 return 130 173 } 131 174 132 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 133 - if err != nil { 134 - log.Println("failed to reach knotserver", err) 175 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 176 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 177 + log.Println("failed to call XRPC repo.tags", xrpcerr) 178 + rp.pages.Error503(w) 135 179 return 136 180 } 137 181 138 182 tagMap := make(map[string][]string) 139 - for _, tag := range tagResult.Tags { 140 - hash := tag.Hash 141 - if tag.Tag != nil { 142 - hash = tag.Tag.Target.String() 183 + if tagBytes != nil { 184 + var tagResp types.RepoTagsResponse 185 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 186 + for _, tag := range tagResp.Tags { 187 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 188 + } 143 189 } 144 - tagMap[hash] = append(tagMap[hash], tag.Name) 145 190 } 146 191 147 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 148 - if err != nil { 149 - log.Println("failed to reach knotserver", err) 192 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 193 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 194 + log.Println("failed to call XRPC repo.branches", xrpcerr) 195 + rp.pages.Error503(w) 150 196 return 151 197 } 152 198 153 - for _, branch := range branchResult.Branches { 154 - hash := branch.Hash 155 - tagMap[hash] = append(tagMap[hash], branch.Name) 199 + if branchBytes != nil { 200 + var branchResp types.RepoBranchesResponse 201 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 202 + for _, branch := range branchResp.Branches { 203 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 204 + } 205 + } 156 206 } 157 207 158 208 user := rp.oauth.GetUser(r) 159 209 160 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 210 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 161 211 if err != nil { 162 212 log.Println("failed to fetch email to did mapping", err) 163 213 } 164 214 165 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 215 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 166 216 if err != nil { 167 217 log.Println(err) 168 218 } ··· 170 220 repoInfo := f.RepoInfo(user) 171 221 172 222 var shas []string 173 - for _, c := range repolog.Commits { 223 + for _, c := range xrpcResp.Commits { 174 224 shas = append(shas, c.Hash.String()) 175 225 } 176 226 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 183 233 LoggedInUser: user, 184 234 TagMap: tagMap, 185 235 RepoInfo: repoInfo, 186 - RepoLogResponse: *repolog, 236 + RepoLogResponse: xrpcResp, 187 237 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 188 238 VerifiedCommits: vc, 189 239 Pipelines: pipelines, ··· 212 262 return 213 263 } 214 264 215 - repoAt := f.RepoAt 265 + repoAt := f.RepoAt() 216 266 rkey := repoAt.RecordKey().String() 217 267 if rkey == "" { 218 268 log.Println("invalid aturi for repo", err) ··· 262 312 Record: &lexutil.LexiconTypeDecoder{ 263 313 Val: &tangled.Repo{ 264 314 Knot: f.Knot, 265 - Name: f.RepoName, 315 + Name: f.Name, 266 316 Owner: user.Did, 267 - CreatedAt: f.CreatedAt, 317 + CreatedAt: f.Created.Format(time.RFC3339), 268 318 Description: &newDescription, 269 319 Spindle: &f.Spindle, 270 320 }, ··· 295 345 return 296 346 } 297 347 ref := chi.URLParam(r, "ref") 298 - protocol := "http" 299 - if !rp.config.Core.Dev { 300 - protocol = "https" 301 - } 348 + ref, _ = url.PathUnescape(ref) 302 349 303 350 var diffOpts types.DiffOpts 304 351 if d := r.URL.Query().Get("diff"); d == "split" { ··· 310 357 return 311 358 } 312 359 313 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 314 - if err != nil { 315 - log.Println("failed to reach knotserver", err) 316 - return 360 + scheme := "http" 361 + if !rp.config.Core.Dev { 362 + scheme = "https" 363 + } 364 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 365 + xrpcc := &indigoxrpc.Client{ 366 + Host: host, 317 367 } 318 368 319 - body, err := io.ReadAll(resp.Body) 320 - if err != nil { 321 - log.Printf("Error reading response body: %v", err) 369 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 370 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 371 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 372 + log.Println("failed to call XRPC repo.diff", xrpcerr) 373 + rp.pages.Error503(w) 322 374 return 323 375 } 324 376 325 377 var result types.RepoCommitResponse 326 - err = json.Unmarshal(body, &result) 327 - if err != nil { 328 - log.Println("failed to parse response:", err) 378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 379 + log.Println("failed to decode XRPC response", err) 380 + rp.pages.Error503(w) 329 381 return 330 382 } 331 383 ··· 370 422 } 371 423 372 424 ref := chi.URLParam(r, "ref") 425 + ref, _ = url.PathUnescape(ref) 426 + 427 + // if the tree path has a trailing slash, let's strip it 428 + // so we don't 404 373 429 treePath := chi.URLParam(r, "*") 374 - protocol := "http" 430 + treePath, _ = url.PathUnescape(treePath) 431 + treePath = strings.TrimSuffix(treePath, "/") 432 + 433 + scheme := "http" 375 434 if !rp.config.Core.Dev { 376 - protocol = "https" 435 + scheme = "https" 377 436 } 378 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 379 - if err != nil { 380 - log.Println("failed to reach knotserver", err) 437 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 438 + xrpcc := &indigoxrpc.Client{ 439 + Host: host, 440 + } 441 + 442 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 443 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 444 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 445 + log.Println("failed to call XRPC repo.tree", xrpcerr) 446 + rp.pages.Error503(w) 381 447 return 382 448 } 383 449 384 - body, err := io.ReadAll(resp.Body) 385 - if err != nil { 386 - log.Printf("Error reading response body: %v", err) 387 - return 450 + // Convert XRPC response to internal types.RepoTreeResponse 451 + files := make([]types.NiceTree, len(xrpcResp.Files)) 452 + for i, xrpcFile := range xrpcResp.Files { 453 + file := types.NiceTree{ 454 + Name: xrpcFile.Name, 455 + Mode: xrpcFile.Mode, 456 + Size: int64(xrpcFile.Size), 457 + IsFile: xrpcFile.Is_file, 458 + IsSubtree: xrpcFile.Is_subtree, 459 + } 460 + 461 + // Convert last commit info if present 462 + if xrpcFile.Last_commit != nil { 463 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 464 + file.LastCommit = &types.LastCommitInfo{ 465 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 466 + Message: xrpcFile.Last_commit.Message, 467 + When: commitWhen, 468 + } 469 + } 470 + 471 + files[i] = file 472 + } 473 + 474 + result := types.RepoTreeResponse{ 475 + Ref: xrpcResp.Ref, 476 + Files: files, 388 477 } 389 478 390 - var result types.RepoTreeResponse 391 - err = json.Unmarshal(body, &result) 392 - if err != nil { 393 - log.Println("failed to parse response:", err) 394 - return 479 + if xrpcResp.Parent != nil { 480 + result.Parent = *xrpcResp.Parent 481 + } 482 + if xrpcResp.Dotdot != nil { 483 + result.DotDot = *xrpcResp.Dotdot 395 484 } 396 485 397 486 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 398 487 // so we can safely redirect to the "parent" (which is the same file). 399 - unescapedTreePath, _ := url.PathUnescape(treePath) 400 - if len(result.Files) == 0 && result.Parent == unescapedTreePath { 401 - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 488 + if len(result.Files) == 0 && result.Parent == treePath { 489 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 490 + http.Redirect(w, r, redirectTo, http.StatusFound) 402 491 return 403 492 } 404 493 405 494 user := rp.oauth.GetUser(r) 406 495 407 496 var breadcrumbs [][]string 408 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 497 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 409 498 if treePath != "" { 410 499 for idx, elem := range strings.Split(treePath, "/") { 411 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 500 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 412 501 } 413 502 } 414 503 ··· 430 519 return 431 520 } 432 521 433 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 434 - if err != nil { 435 - log.Println("failed to create unsigned client", err) 522 + scheme := "http" 523 + if !rp.config.Core.Dev { 524 + scheme = "https" 525 + } 526 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 527 + xrpcc := &indigoxrpc.Client{ 528 + Host: host, 529 + } 530 + 531 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 532 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 533 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 534 + log.Println("failed to call XRPC repo.tags", xrpcerr) 535 + rp.pages.Error503(w) 436 536 return 437 537 } 438 538 439 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 440 - if err != nil { 441 - log.Println("failed to reach knotserver", err) 539 + var result types.RepoTagsResponse 540 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 541 + log.Println("failed to decode XRPC response", err) 542 + rp.pages.Error503(w) 442 543 return 443 544 } 444 545 445 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 546 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 446 547 if err != nil { 447 548 log.Println("failed grab artifacts", err) 448 549 return ··· 474 575 rp.pages.RepoTags(w, pages.RepoTagsParams{ 475 576 LoggedInUser: user, 476 577 RepoInfo: f.RepoInfo(user), 477 - RepoTagsResponse: *result, 578 + RepoTagsResponse: result, 478 579 ArtifactMap: artifactMap, 479 580 DanglingArtifacts: danglingArtifacts, 480 581 }) ··· 487 588 return 488 589 } 489 590 490 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 491 - if err != nil { 492 - log.Println("failed to create unsigned client", err) 591 + scheme := "http" 592 + if !rp.config.Core.Dev { 593 + scheme = "https" 594 + } 595 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 596 + xrpcc := &indigoxrpc.Client{ 597 + Host: host, 598 + } 599 + 600 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 601 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 602 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 603 + log.Println("failed to call XRPC repo.branches", xrpcerr) 604 + rp.pages.Error503(w) 493 605 return 494 606 } 495 607 496 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 497 - if err != nil { 498 - log.Println("failed to reach knotserver", err) 608 + var result types.RepoBranchesResponse 609 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 610 + log.Println("failed to decode XRPC response", err) 611 + rp.pages.Error503(w) 499 612 return 500 613 } 501 614 ··· 505 618 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 506 619 LoggedInUser: user, 507 620 RepoInfo: f.RepoInfo(user), 508 - RepoBranchesResponse: *result, 621 + RepoBranchesResponse: result, 509 622 }) 510 623 } 511 624 ··· 517 630 } 518 631 519 632 ref := chi.URLParam(r, "ref") 633 + ref, _ = url.PathUnescape(ref) 634 + 520 635 filePath := chi.URLParam(r, "*") 521 - protocol := "http" 636 + filePath, _ = url.PathUnescape(filePath) 637 + 638 + scheme := "http" 522 639 if !rp.config.Core.Dev { 523 - protocol = "https" 640 + scheme = "https" 524 641 } 525 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 526 - if err != nil { 527 - log.Println("failed to reach knotserver", err) 528 - return 642 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 643 + xrpcc := &indigoxrpc.Client{ 644 + Host: host, 529 645 } 530 646 531 - body, err := io.ReadAll(resp.Body) 532 - if err != nil { 533 - log.Printf("Error reading response body: %v", err) 647 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 648 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 649 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 650 + log.Println("failed to call XRPC repo.blob", xrpcerr) 651 + rp.pages.Error503(w) 534 652 return 535 653 } 536 654 537 - var result types.RepoBlobResponse 538 - err = json.Unmarshal(body, &result) 539 - if err != nil { 540 - log.Println("failed to parse response:", err) 541 - return 542 - } 655 + // Use XRPC response directly instead of converting to internal types 543 656 544 657 var breadcrumbs [][]string 545 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 658 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 546 659 if filePath != "" { 547 660 for idx, elem := range strings.Split(filePath, "/") { 548 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 661 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 549 662 } 550 663 } 551 664 552 665 showRendered := false 553 666 renderToggle := false 554 667 555 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 668 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 556 669 renderToggle = true 557 670 showRendered = r.URL.Query().Get("code") != "true" 558 671 } ··· 562 675 var isVideo bool 563 676 var contentSrc string 564 677 565 - if result.IsBinary { 566 - ext := strings.ToLower(filepath.Ext(result.Path)) 678 + if resp.IsBinary != nil && *resp.IsBinary { 679 + ext := strings.ToLower(filepath.Ext(resp.Path)) 567 680 switch ext { 568 681 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 569 682 isImage = true ··· 573 686 unsupported = true 574 687 } 575 688 576 - // fetch the actual binary content like in RepoBlobRaw 689 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 690 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 577 691 578 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 692 + baseURL := &url.URL{ 693 + Scheme: scheme, 694 + Host: f.Knot, 695 + Path: "/xrpc/sh.tangled.repo.blob", 696 + } 697 + query := baseURL.Query() 698 + query.Set("repo", repoName) 699 + query.Set("ref", ref) 700 + query.Set("path", filePath) 701 + query.Set("raw", "true") 702 + baseURL.RawQuery = query.Encode() 703 + blobURL := baseURL.String() 704 + 579 705 contentSrc = blobURL 580 706 if !rp.config.Core.Dev { 581 707 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 582 708 } 583 709 } 584 710 711 + lines := 0 712 + if resp.IsBinary == nil || !*resp.IsBinary { 713 + lines = strings.Count(resp.Content, "\n") + 1 714 + } 715 + 716 + var sizeHint uint64 717 + if resp.Size != nil { 718 + sizeHint = uint64(*resp.Size) 719 + } else { 720 + sizeHint = uint64(len(resp.Content)) 721 + } 722 + 585 723 user := rp.oauth.GetUser(r) 724 + 725 + // Determine if content is binary (dereference pointer) 726 + isBinary := false 727 + if resp.IsBinary != nil { 728 + isBinary = *resp.IsBinary 729 + } 730 + 586 731 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 587 - LoggedInUser: user, 588 - RepoInfo: f.RepoInfo(user), 589 - RepoBlobResponse: result, 590 - BreadCrumbs: breadcrumbs, 591 - ShowRendered: showRendered, 592 - RenderToggle: renderToggle, 593 - Unsupported: unsupported, 594 - IsImage: isImage, 595 - IsVideo: isVideo, 596 - ContentSrc: contentSrc, 732 + LoggedInUser: user, 733 + RepoInfo: f.RepoInfo(user), 734 + BreadCrumbs: breadcrumbs, 735 + ShowRendered: showRendered, 736 + RenderToggle: renderToggle, 737 + Unsupported: unsupported, 738 + IsImage: isImage, 739 + IsVideo: isVideo, 740 + ContentSrc: contentSrc, 741 + RepoBlob_Output: resp, 742 + Contents: resp.Content, 743 + Lines: lines, 744 + SizeHint: sizeHint, 745 + IsBinary: isBinary, 597 746 }) 598 747 } 599 748 ··· 606 755 } 607 756 608 757 ref := chi.URLParam(r, "ref") 758 + ref, _ = url.PathUnescape(ref) 759 + 609 760 filePath := chi.URLParam(r, "*") 761 + filePath, _ = url.PathUnescape(filePath) 610 762 611 - protocol := "http" 763 + scheme := "http" 612 764 if !rp.config.Core.Dev { 613 - protocol = "https" 765 + scheme = "https" 766 + } 767 + 768 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 769 + baseURL := &url.URL{ 770 + Scheme: scheme, 771 + Host: f.Knot, 772 + Path: "/xrpc/sh.tangled.repo.blob", 614 773 } 615 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 616 - resp, err := http.Get(blobURL) 774 + query := baseURL.Query() 775 + query.Set("repo", repo) 776 + query.Set("ref", ref) 777 + query.Set("path", filePath) 778 + query.Set("raw", "true") 779 + baseURL.RawQuery = query.Encode() 780 + blobURL := baseURL.String() 781 + 782 + req, err := http.NewRequest("GET", blobURL, nil) 617 783 if err != nil { 618 - log.Println("failed to reach knotserver:", err) 784 + log.Println("failed to create request", err) 785 + return 786 + } 787 + 788 + // forward the If-None-Match header 789 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 790 + req.Header.Set("If-None-Match", clientETag) 791 + } 792 + 793 + client := &http.Client{} 794 + resp, err := client.Do(req) 795 + if err != nil { 796 + log.Println("failed to reach knotserver", err) 619 797 rp.pages.Error503(w) 620 798 return 621 799 } 622 800 defer resp.Body.Close() 623 801 802 + // forward 304 not modified 803 + if resp.StatusCode == http.StatusNotModified { 804 + w.WriteHeader(http.StatusNotModified) 805 + return 806 + } 807 + 624 808 if resp.StatusCode != http.StatusOK { 625 809 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 626 810 w.WriteHeader(resp.StatusCode) ··· 636 820 return 637 821 } 638 822 639 - if strings.Contains(contentType, "text/plain") { 823 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 824 + // serve all textual content as text/plain 640 825 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 641 826 w.Write(body) 642 827 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 828 + // serve images and videos with their original content type 643 829 w.Header().Set("Content-Type", contentType) 644 830 w.Write(body) 645 831 } else { ··· 649 835 } 650 836 } 651 837 838 + // isTextualMimeType returns true if the MIME type represents textual content 839 + // that should be served as text/plain 840 + func isTextualMimeType(mimeType string) bool { 841 + textualTypes := []string{ 842 + "application/json", 843 + "application/xml", 844 + "application/yaml", 845 + "application/x-yaml", 846 + "application/toml", 847 + "application/javascript", 848 + "application/ecmascript", 849 + "message/", 850 + } 851 + 852 + return slices.Contains(textualTypes, mimeType) 853 + } 854 + 652 855 // modify the spindle configured for this repo 653 856 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 654 857 user := rp.oauth.GetUser(r) ··· 668 871 return 669 872 } 670 873 671 - repoAt := f.RepoAt 874 + repoAt := f.RepoAt() 672 875 rkey := repoAt.RecordKey().String() 673 876 if rkey == "" { 674 877 fail("Failed to resolve repo. Try again later", err) ··· 722 925 Record: &lexutil.LexiconTypeDecoder{ 723 926 Val: &tangled.Repo{ 724 927 Knot: f.Knot, 725 - Name: f.RepoName, 928 + Name: f.Name, 726 929 Owner: user.Did, 727 - CreatedAt: f.CreatedAt, 930 + CreatedAt: f.Created.Format(time.RFC3339), 728 931 Description: &f.Description, 729 932 Spindle: spindlePtr, 730 933 }, ··· 805 1008 Record: &lexutil.LexiconTypeDecoder{ 806 1009 Val: &tangled.RepoCollaborator{ 807 1010 Subject: collaboratorIdent.DID.String(), 808 - Repo: string(f.RepoAt), 1011 + Repo: string(f.RepoAt()), 809 1012 CreatedAt: createdAt.Format(time.RFC3339), 810 1013 }}, 811 1014 }) ··· 814 1017 fail("Failed to write record to PDS.", err) 815 1018 return 816 1019 } 817 - l = l.With("at-uri", resp.Uri) 1020 + 1021 + aturi := resp.Uri 1022 + l = l.With("at-uri", aturi) 818 1023 l.Info("wrote record to PDS") 819 1024 820 - l.Info("adding to knot") 821 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1025 + tx, err := rp.db.BeginTx(r.Context(), nil) 822 1026 if err != nil { 823 - fail("Failed to add to knot.", err) 1027 + fail("Failed to add collaborator.", err) 824 1028 return 825 1029 } 826 1030 827 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 828 - if err != nil { 829 - fail("Failed to add to knot.", err) 830 - return 831 - } 1031 + rollback := func() { 1032 + err1 := tx.Rollback() 1033 + err2 := rp.enforcer.E.LoadPolicy() 1034 + err3 := rollbackRecord(context.Background(), aturi, client) 832 1035 833 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 834 - if err != nil { 835 - fail("Knot was unreachable.", err) 836 - return 837 - } 1036 + // ignore txn complete errors, this is okay 1037 + if errors.Is(err1, sql.ErrTxDone) { 1038 + err1 = nil 1039 + } 838 1040 839 - if ksResp.StatusCode != http.StatusNoContent { 840 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 841 - return 1041 + if errs := errors.Join(err1, err2, err3); errs != nil { 1042 + l.Error("failed to rollback changes", "errs", errs) 1043 + return 1044 + } 842 1045 } 843 - 844 - tx, err := rp.db.BeginTx(r.Context(), nil) 845 - if err != nil { 846 - fail("Failed to add collaborator.", err) 847 - return 848 - } 849 - defer func() { 850 - tx.Rollback() 851 - err = rp.enforcer.E.LoadPolicy() 852 - if err != nil { 853 - fail("Failed to add collaborator.", err) 854 - } 855 - }() 1046 + defer rollback() 856 1047 857 1048 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 858 1049 if err != nil { ··· 864 1055 Did: syntax.DID(currentUser.Did), 865 1056 Rkey: rkey, 866 1057 SubjectDid: collaboratorIdent.DID, 867 - RepoAt: f.RepoAt, 1058 + RepoAt: f.RepoAt(), 868 1059 Created: createdAt, 869 1060 }) 870 1061 if err != nil { ··· 884 1075 return 885 1076 } 886 1077 1078 + // clear aturi to when everything is successful 1079 + aturi = "" 1080 + 887 1081 rp.pages.HxRefresh(w) 888 1082 } 889 1083 890 1084 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 891 1085 user := rp.oauth.GetUser(r) 892 1086 1087 + noticeId := "operation-error" 893 1088 f, err := rp.repoResolver.Resolve(r) 894 1089 if err != nil { 895 1090 log.Println("failed to get repo and knot", err) ··· 902 1097 log.Println("failed to get authorized client", err) 903 1098 return 904 1099 } 905 - repoRkey := f.RepoAt.RecordKey().String() 906 1100 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 907 1101 Collection: tangled.RepoNSID, 908 1102 Repo: user.Did, 909 - Rkey: repoRkey, 1103 + Rkey: f.Rkey, 910 1104 }) 911 1105 if err != nil { 912 1106 log.Printf("failed to delete record: %s", err) 913 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 914 - return 915 - } 916 - log.Println("removed repo record ", f.RepoAt.String()) 917 - 918 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 919 - if err != nil { 920 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1107 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 921 1108 return 922 1109 } 1110 + log.Println("removed repo record ", f.RepoAt().String()) 923 1111 924 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1112 + client, err := rp.oauth.ServiceClient( 1113 + r, 1114 + oauth.WithService(f.Knot), 1115 + oauth.WithLxm(tangled.RepoDeleteNSID), 1116 + oauth.WithDev(rp.config.Core.Dev), 1117 + ) 925 1118 if err != nil { 926 - log.Println("failed to create client to ", f.Knot) 1119 + log.Println("failed to connect to knot server:", err) 927 1120 return 928 1121 } 929 1122 930 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 931 - if err != nil { 932 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1123 + err = tangled.RepoDelete( 1124 + r.Context(), 1125 + client, 1126 + &tangled.RepoDelete_Input{ 1127 + Did: f.OwnerDid(), 1128 + Name: f.Name, 1129 + Rkey: f.Rkey, 1130 + }, 1131 + ) 1132 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1133 + rp.pages.Notice(w, noticeId, err.Error()) 933 1134 return 934 1135 } 935 - 936 - if ksResp.StatusCode != http.StatusNoContent { 937 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 938 - } else { 939 - log.Println("removed repo from knot ", f.Knot) 940 - } 1136 + log.Println("deleted repo from knot") 941 1137 942 1138 tx, err := rp.db.BeginTx(r.Context(), nil) 943 1139 if err != nil { ··· 956 1152 // remove collaborator RBAC 957 1153 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 958 1154 if err != nil { 959 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1155 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 960 1156 return 961 1157 } 962 1158 for _, c := range repoCollaborators { ··· 968 1164 // remove repo RBAC 969 1165 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 970 1166 if err != nil { 971 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1167 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 972 1168 return 973 1169 } 974 1170 975 1171 // remove repo from db 976 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 1172 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 977 1173 if err != nil { 978 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1174 + rp.pages.Notice(w, noticeId, "Failed to update appview") 979 1175 return 980 1176 } 981 1177 log.Println("removed repo from db") ··· 1004 1200 return 1005 1201 } 1006 1202 1203 + noticeId := "operation-error" 1007 1204 branch := r.FormValue("branch") 1008 1205 if branch == "" { 1009 1206 http.Error(w, "malformed form", http.StatusBadRequest) 1010 1207 return 1011 1208 } 1012 1209 1013 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1210 + client, err := rp.oauth.ServiceClient( 1211 + r, 1212 + oauth.WithService(f.Knot), 1213 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1214 + oauth.WithDev(rp.config.Core.Dev), 1215 + ) 1014 1216 if err != nil { 1015 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1016 - return 1017 - } 1018 - 1019 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1020 - if err != nil { 1021 - log.Println("failed to create client to ", f.Knot) 1022 - return 1023 - } 1024 - 1025 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1026 - if err != nil { 1027 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1217 + log.Println("failed to connect to knot server:", err) 1218 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1028 1219 return 1029 1220 } 1030 1221 1031 - if ksResp.StatusCode != http.StatusNoContent { 1032 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1222 + xe := tangled.RepoSetDefaultBranch( 1223 + r.Context(), 1224 + client, 1225 + &tangled.RepoSetDefaultBranch_Input{ 1226 + Repo: f.RepoAt().String(), 1227 + DefaultBranch: branch, 1228 + }, 1229 + ) 1230 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1231 + log.Println("xrpc failed", "err", xe) 1232 + rp.pages.Notice(w, noticeId, err.Error()) 1033 1233 return 1034 1234 } 1035 1235 1036 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1236 + rp.pages.HxRefresh(w) 1037 1237 } 1038 1238 1039 1239 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1062 1262 r, 1063 1263 oauth.WithService(f.Spindle), 1064 1264 oauth.WithLxm(lxm), 1265 + oauth.WithExp(60), 1065 1266 oauth.WithDev(rp.config.Core.Dev), 1066 1267 ) 1067 1268 if err != nil { ··· 1089 1290 r.Context(), 1090 1291 spindleClient, 1091 1292 &tangled.RepoAddSecret_Input{ 1092 - Repo: f.RepoAt.String(), 1293 + Repo: f.RepoAt().String(), 1093 1294 Key: key, 1094 1295 Value: value, 1095 1296 }, ··· 1107 1308 r.Context(), 1108 1309 spindleClient, 1109 1310 &tangled.RepoRemoveSecret_Input{ 1110 - Repo: f.RepoAt.String(), 1311 + Repo: f.RepoAt().String(), 1111 1312 Key: key, 1112 1313 }, 1113 1314 ) ··· 1148 1349 case "pipelines": 1149 1350 rp.pipelineSettings(w, r) 1150 1351 } 1151 - 1152 - // user := rp.oauth.GetUser(r) 1153 - // repoCollaborators, err := f.Collaborators(r.Context()) 1154 - // if err != nil { 1155 - // log.Println("failed to get collaborators", err) 1156 - // } 1157 - 1158 - // isCollaboratorInviteAllowed := false 1159 - // if user != nil { 1160 - // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1161 - // if err == nil && ok { 1162 - // isCollaboratorInviteAllowed = true 1163 - // } 1164 - // } 1165 - 1166 - // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1167 - // if err != nil { 1168 - // log.Println("failed to create unsigned client", err) 1169 - // return 1170 - // } 1171 - 1172 - // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1173 - // if err != nil { 1174 - // log.Println("failed to reach knotserver", err) 1175 - // return 1176 - // } 1177 - 1178 - // // all spindles that this user is a member of 1179 - // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1180 - // if err != nil { 1181 - // log.Println("failed to fetch spindles", err) 1182 - // return 1183 - // } 1184 - 1185 - // var secrets []*tangled.RepoListSecrets_Secret 1186 - // if f.Spindle != "" { 1187 - // if spindleClient, err := rp.oauth.ServiceClient( 1188 - // r, 1189 - // oauth.WithService(f.Spindle), 1190 - // oauth.WithLxm(tangled.RepoListSecretsNSID), 1191 - // oauth.WithDev(rp.config.Core.Dev), 1192 - // ); err != nil { 1193 - // log.Println("failed to create spindle client", err) 1194 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1195 - // log.Println("failed to fetch secrets", err) 1196 - // } else { 1197 - // secrets = resp.Secrets 1198 - // } 1199 - // } 1200 - 1201 - // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1202 - // LoggedInUser: user, 1203 - // RepoInfo: f.RepoInfo(user), 1204 - // Collaborators: repoCollaborators, 1205 - // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1206 - // Branches: result.Branches, 1207 - // Spindles: spindles, 1208 - // CurrentSpindle: f.Spindle, 1209 - // Secrets: secrets, 1210 - // }) 1211 1352 } 1212 1353 1213 1354 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1214 1355 f, err := rp.repoResolver.Resolve(r) 1215 1356 user := rp.oauth.GetUser(r) 1216 1357 1217 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1218 - if err != nil { 1219 - log.Println("failed to create unsigned client", err) 1358 + scheme := "http" 1359 + if !rp.config.Core.Dev { 1360 + scheme = "https" 1361 + } 1362 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1363 + xrpcc := &indigoxrpc.Client{ 1364 + Host: host, 1365 + } 1366 + 1367 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1368 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1369 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1370 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1371 + rp.pages.Error503(w) 1220 1372 return 1221 1373 } 1222 1374 1223 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1224 - if err != nil { 1225 - log.Println("failed to reach knotserver", err) 1375 + var result types.RepoBranchesResponse 1376 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1377 + log.Println("failed to decode XRPC response", err) 1378 + rp.pages.Error503(w) 1226 1379 return 1227 1380 } 1228 1381 ··· 1270 1423 r, 1271 1424 oauth.WithService(f.Spindle), 1272 1425 oauth.WithLxm(tangled.RepoListSecretsNSID), 1426 + oauth.WithExp(60), 1273 1427 oauth.WithDev(rp.config.Core.Dev), 1274 1428 ); err != nil { 1275 1429 log.Println("failed to create spindle client", err) 1276 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1430 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1277 1431 log.Println("failed to fetch secrets", err) 1278 1432 } else { 1279 1433 secrets = resp.Secrets ··· 1314 1468 } 1315 1469 1316 1470 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1471 + ref := chi.URLParam(r, "ref") 1472 + ref, _ = url.PathUnescape(ref) 1473 + 1317 1474 user := rp.oauth.GetUser(r) 1318 1475 f, err := rp.repoResolver.Resolve(r) 1319 1476 if err != nil { ··· 1323 1480 1324 1481 switch r.Method { 1325 1482 case http.MethodPost: 1326 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1483 + client, err := rp.oauth.ServiceClient( 1484 + r, 1485 + oauth.WithService(f.Knot), 1486 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1487 + oauth.WithDev(rp.config.Core.Dev), 1488 + ) 1327 1489 if err != nil { 1328 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1490 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1329 1491 return 1330 1492 } 1331 1493 1332 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1333 - if err != nil { 1334 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1494 + repoInfo := f.RepoInfo(user) 1495 + if repoInfo.Source == nil { 1496 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1335 1497 return 1336 1498 } 1337 1499 1338 - var uri string 1339 - if rp.config.Core.Dev { 1340 - uri = "http" 1341 - } else { 1342 - uri = "https" 1343 - } 1344 - forkName := fmt.Sprintf("%s", f.RepoName) 1345 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1346 - 1347 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1348 - if err != nil { 1349 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1500 + err = tangled.RepoForkSync( 1501 + r.Context(), 1502 + client, 1503 + &tangled.RepoForkSync_Input{ 1504 + Did: user.Did, 1505 + Name: f.Name, 1506 + Source: repoInfo.Source.RepoAt().String(), 1507 + Branch: ref, 1508 + }, 1509 + ) 1510 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1511 + rp.pages.Notice(w, "repo", err.Error()) 1350 1512 return 1351 1513 } 1352 1514 ··· 1379 1541 }) 1380 1542 1381 1543 case http.MethodPost: 1544 + l := rp.logger.With("handler", "ForkRepo") 1382 1545 1383 - knot := r.FormValue("knot") 1384 - if knot == "" { 1546 + targetKnot := r.FormValue("knot") 1547 + if targetKnot == "" { 1385 1548 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1386 1549 return 1387 1550 } 1551 + l = l.With("targetKnot", targetKnot) 1388 1552 1389 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1553 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1390 1554 if err != nil || !ok { 1391 1555 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1392 1556 return 1393 1557 } 1394 1558 1395 - forkName := fmt.Sprintf("%s", f.RepoName) 1396 - 1559 + // choose a name for a fork 1560 + forkName := f.Name 1397 1561 // this check is *only* to see if the forked repo name already exists 1398 1562 // in the user's account. 1399 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1563 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1400 1564 if err != nil { 1401 1565 if errors.Is(err, sql.ErrNoRows) { 1402 1566 // no existing repo with this name found, we can use the name as is ··· 1409 1573 // repo with this name already exists, append random string 1410 1574 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1411 1575 } 1412 - secret, err := db.GetRegistrationKey(rp.db, knot) 1413 - if err != nil { 1414 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1415 - return 1416 - } 1576 + l = l.With("forkName", forkName) 1417 1577 1418 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1419 - if err != nil { 1420 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1421 - return 1422 - } 1423 - 1424 - var uri string 1578 + uri := "https" 1425 1579 if rp.config.Core.Dev { 1426 1580 uri = "http" 1427 - } else { 1428 - uri = "https" 1429 1581 } 1430 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1431 - sourceAt := f.RepoAt.String() 1582 + 1583 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1584 + l = l.With("cloneUrl", forkSourceUrl) 1432 1585 1586 + sourceAt := f.RepoAt().String() 1587 + 1588 + // create an atproto record for this fork 1433 1589 rkey := tid.TID() 1434 1590 repo := &db.Repo{ 1435 1591 Did: user.Did, 1436 1592 Name: forkName, 1437 - Knot: knot, 1593 + Knot: targetKnot, 1438 1594 Rkey: rkey, 1439 1595 Source: sourceAt, 1440 1596 } 1441 1597 1442 - tx, err := rp.db.BeginTx(r.Context(), nil) 1443 - if err != nil { 1444 - log.Println(err) 1445 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1446 - return 1447 - } 1448 - defer func() { 1449 - tx.Rollback() 1450 - err = rp.enforcer.E.LoadPolicy() 1451 - if err != nil { 1452 - log.Println("failed to rollback policies") 1453 - } 1454 - }() 1455 - 1456 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1457 - if err != nil { 1458 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1459 - return 1460 - } 1461 - 1462 - switch resp.StatusCode { 1463 - case http.StatusConflict: 1464 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1465 - return 1466 - case http.StatusInternalServerError: 1467 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1468 - case http.StatusNoContent: 1469 - // continue 1470 - } 1471 - 1472 1598 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1473 1599 if err != nil { 1474 - log.Println("failed to get authorized client", err) 1475 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1600 + l.Error("failed to create xrpcclient", "err", err) 1601 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1476 1602 return 1477 1603 } 1478 1604 ··· 1491 1617 }}, 1492 1618 }) 1493 1619 if err != nil { 1494 - log.Printf("failed to create record: %s", err) 1620 + l.Error("failed to write to PDS", "err", err) 1495 1621 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1496 1622 return 1497 1623 } 1498 - log.Println("created repo record: ", atresp.Uri) 1624 + 1625 + aturi := atresp.Uri 1626 + l = l.With("aturi", aturi) 1627 + l.Info("wrote to PDS") 1628 + 1629 + tx, err := rp.db.BeginTx(r.Context(), nil) 1630 + if err != nil { 1631 + l.Info("txn failed", "err", err) 1632 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1633 + return 1634 + } 1635 + 1636 + // The rollback function reverts a few things on failure: 1637 + // - the pending txn 1638 + // - the ACLs 1639 + // - the atproto record created 1640 + rollback := func() { 1641 + err1 := tx.Rollback() 1642 + err2 := rp.enforcer.E.LoadPolicy() 1643 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1644 + 1645 + // ignore txn complete errors, this is okay 1646 + if errors.Is(err1, sql.ErrTxDone) { 1647 + err1 = nil 1648 + } 1649 + 1650 + if errs := errors.Join(err1, err2, err3); errs != nil { 1651 + l.Error("failed to rollback changes", "errs", errs) 1652 + return 1653 + } 1654 + } 1655 + defer rollback() 1499 1656 1500 - repo.AtUri = atresp.Uri 1657 + client, err := rp.oauth.ServiceClient( 1658 + r, 1659 + oauth.WithService(targetKnot), 1660 + oauth.WithLxm(tangled.RepoCreateNSID), 1661 + oauth.WithDev(rp.config.Core.Dev), 1662 + ) 1663 + if err != nil { 1664 + l.Error("could not create service client", "err", err) 1665 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1666 + return 1667 + } 1668 + 1669 + err = tangled.RepoCreate( 1670 + r.Context(), 1671 + client, 1672 + &tangled.RepoCreate_Input{ 1673 + Rkey: rkey, 1674 + Source: &forkSourceUrl, 1675 + }, 1676 + ) 1677 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1678 + rp.pages.Notice(w, "repo", err.Error()) 1679 + return 1680 + } 1681 + 1501 1682 err = db.AddRepo(tx, repo) 1502 1683 if err != nil { 1503 1684 log.Println(err) ··· 1507 1688 1508 1689 // acls 1509 1690 p, _ := securejoin.SecureJoin(user.Did, forkName) 1510 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1691 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1511 1692 if err != nil { 1512 1693 log.Println(err) 1513 1694 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1528 1709 return 1529 1710 } 1530 1711 1712 + // reset the ATURI because the transaction completed successfully 1713 + aturi = "" 1714 + 1715 + rp.notifier.NewRepo(r.Context(), repo) 1531 1716 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1532 - return 1533 1717 } 1534 1718 } 1535 1719 1720 + // this is used to rollback changes made to the PDS 1721 + // 1722 + // it is a no-op if the provided ATURI is empty 1723 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1724 + if aturi == "" { 1725 + return nil 1726 + } 1727 + 1728 + parsed := syntax.ATURI(aturi) 1729 + 1730 + collection := parsed.Collection().String() 1731 + repo := parsed.Authority().String() 1732 + rkey := parsed.RecordKey().String() 1733 + 1734 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1735 + Collection: collection, 1736 + Repo: repo, 1737 + Rkey: rkey, 1738 + }) 1739 + return err 1740 + } 1741 + 1536 1742 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1537 1743 user := rp.oauth.GetUser(r) 1538 1744 f, err := rp.repoResolver.Resolve(r) ··· 1541 1747 return 1542 1748 } 1543 1749 1544 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1545 - if err != nil { 1546 - log.Printf("failed to create unsigned client for %s", f.Knot) 1750 + scheme := "http" 1751 + if !rp.config.Core.Dev { 1752 + scheme = "https" 1753 + } 1754 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1755 + xrpcc := &indigoxrpc.Client{ 1756 + Host: host, 1757 + } 1758 + 1759 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1760 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1761 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1762 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1547 1763 rp.pages.Error503(w) 1548 1764 return 1549 1765 } 1550 1766 1551 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1552 - if err != nil { 1767 + var branchResult types.RepoBranchesResponse 1768 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1769 + log.Println("failed to decode XRPC branches response", err) 1553 1770 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1554 - log.Println("failed to reach knotserver", err) 1555 1771 return 1556 1772 } 1557 - branches := result.Branches 1773 + branches := branchResult.Branches 1558 1774 1559 1775 sortBranches(branches) 1560 1776 ··· 1578 1794 head = queryHead 1579 1795 } 1580 1796 1581 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1582 - if err != nil { 1797 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1798 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1799 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1800 + rp.pages.Error503(w) 1801 + return 1802 + } 1803 + 1804 + var tags types.RepoTagsResponse 1805 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1806 + log.Println("failed to decode XRPC tags response", err) 1583 1807 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1584 - log.Println("failed to reach knotserver", err) 1585 1808 return 1586 1809 } 1587 1810 ··· 1633 1856 return 1634 1857 } 1635 1858 1636 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1637 - if err != nil { 1638 - log.Printf("failed to create unsigned client for %s", f.Knot) 1859 + scheme := "http" 1860 + if !rp.config.Core.Dev { 1861 + scheme = "https" 1862 + } 1863 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1864 + xrpcc := &indigoxrpc.Client{ 1865 + Host: host, 1866 + } 1867 + 1868 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1869 + 1870 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1871 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1872 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1639 1873 rp.pages.Error503(w) 1640 1874 return 1641 1875 } 1642 1876 1643 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1644 - if err != nil { 1877 + var branches types.RepoBranchesResponse 1878 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1879 + log.Println("failed to decode XRPC branches response", err) 1645 1880 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1646 - log.Println("failed to reach knotserver", err) 1881 + return 1882 + } 1883 + 1884 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1885 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1886 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1887 + rp.pages.Error503(w) 1647 1888 return 1648 1889 } 1649 1890 1650 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1651 - if err != nil { 1891 + var tags types.RepoTagsResponse 1892 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1893 + log.Println("failed to decode XRPC tags response", err) 1652 1894 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1653 - log.Println("failed to reach knotserver", err) 1895 + return 1896 + } 1897 + 1898 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1899 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1900 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1901 + rp.pages.Error503(w) 1654 1902 return 1655 1903 } 1656 1904 1657 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1658 - if err != nil { 1905 + var formatPatch types.RepoFormatPatchResponse 1906 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1907 + log.Println("failed to decode XRPC compare response", err) 1659 1908 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1660 - log.Println("failed to compare", err) 1661 1909 return 1662 1910 } 1911 + 1663 1912 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1664 1913 1665 1914 repoinfo := f.RepoInfo(user)
+1
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 -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 -9
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" ··· 227 228 } 228 229 229 230 // begin verification 230 - 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) 231 232 if err != nil { 232 233 l.Error("verification failed", "err", err) 233 234 s.Pages.HxRefresh(w) 234 235 return 235 236 } 236 237 237 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 238 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 238 239 if err != nil { 239 240 l.Error("failed to mark verified", "err", err) 240 241 s.Pages.HxRefresh(w) ··· 400 401 } 401 402 402 403 // begin verification 403 - 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) 404 405 if err != nil { 405 406 l.Error("verification failed", "err", err) 406 407 407 - if errors.Is(err, verify.FetchError) { 408 - 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!") 409 410 return 410 411 } 411 412 412 - if e, ok := err.(*verify.OwnerMismatch); ok { 413 + if e, ok := err.(*serververify.OwnerMismatch); ok { 413 414 s.Pages.Notice(w, noticeId, e.Error()) 414 415 return 415 416 } ··· 418 419 return 419 420 } 420 421 421 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 422 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 422 423 if err != nil { 423 424 l.Error("failed to mark verified", "err", err) 424 425 s.Pages.Notice(w, noticeId, err.Error()) ··· 442 443 } 443 444 444 445 w.Header().Set("HX-Reswap", "outerHTML") 445 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 446 447 } 447 448 448 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
+375 -152
appview/state/profile.go
··· 23 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 24 tabVal := r.URL.Query().Get("tab") 25 25 switch tabVal { 26 - case "": 27 - s.profilePage(w, r) 28 26 case "repos": 29 27 s.reposPage(w, r) 28 + case "followers": 29 + s.followersPage(w, r) 30 + case "following": 31 + s.followingPage(w, r) 32 + case "starred": 33 + s.starredPage(w, r) 34 + case "strings": 35 + s.stringsPage(w, r) 36 + default: 37 + s.profileOverview(w, r) 30 38 } 31 39 } 32 40 33 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 34 42 didOrHandle := chi.URLParam(r, "user") 35 43 if didOrHandle == "" { 36 - http.Error(w, "Bad request", http.StatusBadRequest) 37 - return 44 + return nil, fmt.Errorf("empty DID or handle") 38 45 } 39 46 40 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 41 48 if !ok { 42 - s.pages.Error404(w) 43 - return 49 + return nil, fmt.Errorf("failed to resolve ID") 44 50 } 51 + did := ident.DID.String() 45 52 46 - profile, err := db.GetProfile(s.db, ident.DID.String()) 53 + profile, err := db.GetProfile(s.db, did) 47 54 if err != nil { 48 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 55 + return nil, fmt.Errorf("failed to get profile: %w", err) 49 56 } 50 57 58 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 59 + if err != nil { 60 + return nil, fmt.Errorf("failed to get repo count: %w", err) 61 + } 62 + 63 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get string count: %w", err) 66 + } 67 + 68 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 + if err != nil { 70 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 71 + } 72 + 73 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 76 + } 77 + 78 + loggedInUser := s.oauth.GetUser(r) 79 + followStatus := db.IsNotFollowing 80 + if loggedInUser != nil { 81 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 + } 83 + 84 + now := time.Now() 85 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 86 + punchcard, err := db.MakePunchcard( 87 + s.db, 88 + db.FilterEq("did", did), 89 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 90 + db.FilterLte("date", now.Format(time.DateOnly)), 91 + ) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 94 + } 95 + 96 + return &pages.ProfileCard{ 97 + UserDid: did, 98 + UserHandle: ident.Handle.String(), 99 + Profile: profile, 100 + FollowStatus: followStatus, 101 + Stats: pages.ProfileStats{ 102 + RepoCount: repoCount, 103 + StringCount: stringCount, 104 + StarredCount: starredCount, 105 + FollowersCount: followStats.Followers, 106 + FollowingCount: followStats.Following, 107 + }, 108 + Punchcard: punchcard, 109 + }, nil 110 + } 111 + 112 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 113 + l := s.logger.With("handler", "profileHomePage") 114 + 115 + profile, err := s.profile(r) 116 + if err != nil { 117 + l.Error("failed to build profile card", "err", err) 118 + s.pages.Error500(w) 119 + return 120 + } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 + 51 123 repos, err := db.GetRepos( 52 124 s.db, 53 125 0, 54 - db.FilterEq("did", ident.DID.String()), 126 + db.FilterEq("did", profile.UserDid), 55 127 ) 56 128 if err != nil { 57 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 129 + l.Error("failed to fetch repos", "err", err) 58 130 } 59 131 60 132 // filter out ones that are pinned 61 133 pinnedRepos := []db.Repo{} 62 134 for i, r := range repos { 63 135 // if this is a pinned repo, add it 64 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 65 137 pinnedRepos = append(pinnedRepos, r) 66 138 } 67 139 68 140 // if there are no saved pins, add the first 4 repos 69 - if profile.IsPinnedReposEmpty() && i < 4 { 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 70 142 pinnedRepos = append(pinnedRepos, r) 71 143 } 72 144 } 73 145 74 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 75 147 if err != nil { 76 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 148 + l.Error("failed to fetch collaborating repos", "err", err) 77 149 } 78 150 79 151 pinnedCollaboratingRepos := []db.Repo{} 80 152 for _, r := range collaboratingRepos { 81 153 // if this is a pinned repo, add it 82 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 83 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 84 156 } 85 157 } 86 158 87 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 88 160 if err != nil { 89 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 161 + l.Error("failed to create timeline", "err", err) 90 162 } 91 163 92 - var didsToResolve []string 93 - for _, r := range collaboratingRepos { 94 - didsToResolve = append(didsToResolve, r.Did) 95 - } 96 - for _, byMonth := range timeline.ByMonth { 97 - for _, pe := range byMonth.PullEvents.Items { 98 - didsToResolve = append(didsToResolve, pe.Repo.Did) 99 - } 100 - for _, ie := range byMonth.IssueEvents.Items { 101 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 102 - } 103 - for _, re := range byMonth.RepoEvents { 104 - didsToResolve = append(didsToResolve, re.Repo.Did) 105 - if re.Source != nil { 106 - didsToResolve = append(didsToResolve, re.Source.Did) 107 - } 108 - } 109 - } 164 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 165 + LoggedInUser: s.oauth.GetUser(r), 166 + Card: profile, 167 + Repos: pinnedRepos, 168 + CollaboratingRepos: pinnedCollaboratingRepos, 169 + ProfileTimeline: timeline, 170 + }) 171 + } 172 + 173 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 174 + l := s.logger.With("handler", "reposPage") 110 175 111 - followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 176 + profile, err := s.profile(r) 112 177 if err != nil { 113 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 178 + l.Error("failed to build profile card", "err", err) 179 + s.pages.Error500(w) 180 + return 114 181 } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 115 183 116 - loggedInUser := s.oauth.GetUser(r) 117 - followStatus := db.IsNotFollowing 118 - if loggedInUser != nil { 119 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 120 - } 121 - 122 - now := time.Now() 123 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 124 - punchcard, err := db.MakePunchcard( 184 + repos, err := db.GetRepos( 125 185 s.db, 126 - db.FilterEq("did", ident.DID.String()), 127 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 128 - db.FilterLte("date", now.Format(time.DateOnly)), 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 129 188 ) 130 189 if err != nil { 131 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 132 193 } 133 194 134 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 135 - LoggedInUser: loggedInUser, 136 - Repos: pinnedRepos, 137 - CollaboratingRepos: pinnedCollaboratingRepos, 138 - Card: pages.ProfileCard{ 139 - UserDid: ident.DID.String(), 140 - UserHandle: ident.Handle.String(), 141 - Profile: profile, 142 - FollowStatus: followStatus, 143 - Followers: followers, 144 - Following: following, 145 - }, 146 - Punchcard: punchcard, 147 - ProfileTimeline: timeline, 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 148 199 }) 149 200 } 150 201 151 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 152 - ident, ok := r.Context().Value("resolvedId").(identity.Identity) 153 - if !ok { 154 - s.pages.Error404(w) 202 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 203 + l := s.logger.With("handler", "starredPage") 204 + 205 + profile, err := s.profile(r) 206 + if err != nil { 207 + l.Error("failed to build profile card", "err", err) 208 + s.pages.Error500(w) 155 209 return 156 210 } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 157 212 158 - profile, err := db.GetProfile(s.db, ident.DID.String()) 213 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 159 214 if err != nil { 160 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 215 + l.Error("failed to get stars", "err", err) 216 + s.pages.Error500(w) 217 + return 218 + } 219 + var repoAts []string 220 + for _, s := range stars { 221 + repoAts = append(repoAts, string(s.RepoAt)) 161 222 } 162 223 163 224 repos, err := db.GetRepos( 164 225 s.db, 165 226 0, 166 - db.FilterEq("did", ident.DID.String()), 227 + db.FilterIn("at_uri", repoAts), 167 228 ) 168 229 if err != nil { 169 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 233 + } 234 + 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 237 + Repos: repos, 238 + Card: profile, 239 + }) 240 + } 241 + 242 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 243 + l := s.logger.With("handler", "stringsPage") 244 + 245 + profile, err := s.profile(r) 246 + if err != nil { 247 + l.Error("failed to build profile card", "err", err) 248 + s.pages.Error500(w) 249 + return 170 250 } 251 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 252 + 253 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 254 + if err != nil { 255 + l.Error("failed to get strings", "err", err) 256 + s.pages.Error500(w) 257 + return 258 + } 259 + 260 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 261 + LoggedInUser: s.oauth.GetUser(r), 262 + Strings: strings, 263 + Card: profile, 264 + }) 265 + } 266 + 267 + type FollowsPageParams struct { 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 270 + } 271 + 272 + func (s *State) followPage( 273 + r *http.Request, 274 + fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 + extractDid func(db.Follow) string, 276 + ) (*FollowsPageParams, error) { 277 + l := s.logger.With("handler", "reposPage") 278 + 279 + profile, err := s.profile(r) 280 + if err != nil { 281 + return nil, err 282 + } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 171 284 172 285 loggedInUser := s.oauth.GetUser(r) 173 - followStatus := db.IsNotFollowing 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 289 + 290 + follows, err := fetchFollows(s.db, profile.UserDid) 291 + if err != nil { 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 294 + } 295 + 296 + if len(follows) == 0 { 297 + return &params, nil 298 + } 299 + 300 + followDids := make([]string, 0, len(follows)) 301 + for _, follow := range follows { 302 + followDids = append(followDids, extractDid(follow)) 303 + } 304 + 305 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 306 + if err != nil { 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 309 + } 310 + 311 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 312 + if err != nil { 313 + log.Printf("getting follow counts for %s: %s", followDids, err) 314 + } 315 + 316 + loggedInUserFollowing := make(map[string]struct{}) 174 317 if loggedInUser != nil { 175 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 318 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 + if err != nil { 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 322 + } 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 326 + } 176 327 } 177 328 178 - followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 332 + followStatus := db.IsNotFollowing 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 337 + } 338 + 339 + var profile *db.Profile 340 + if p, exists := profiles[did]; exists { 341 + profile = p 342 + } else { 343 + profile = &db.Profile{} 344 + profile.Did = did 345 + } 346 + followCards[i] = pages.FollowCard{ 347 + UserDid: did, 348 + FollowStatus: followStatus, 349 + FollowersCount: followStats.Followers, 350 + FollowingCount: followStats.Following, 351 + Profile: profile, 352 + } 353 + } 354 + 355 + params.Follows = followCards 356 + 357 + return &params, nil 358 + } 359 + 360 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 179 362 if err != nil { 180 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 363 + s.pages.Notice(w, "all-followers", "Failed to load followers") 364 + return 181 365 } 182 366 183 - s.pages.ReposPage(w, pages.ReposPageParams{ 184 - LoggedInUser: loggedInUser, 185 - Repos: repos, 186 - Card: pages.ProfileCard{ 187 - UserDid: ident.DID.String(), 188 - UserHandle: ident.Handle.String(), 189 - Profile: profile, 190 - FollowStatus: followStatus, 191 - Followers: followers, 192 - Following: following, 193 - }, 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 369 + Followers: followPage.Follows, 370 + Card: followPage.Card, 194 371 }) 195 372 } 196 373 197 - func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { 374 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 376 + if err != nil { 377 + s.pages.Notice(w, "all-following", "Failed to load following") 378 + return 379 + } 380 + 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 383 + Following: followPage.Follows, 384 + Card: followPage.Card, 385 + }) 386 + } 387 + 388 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 198 389 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 199 390 if !ok { 200 391 s.pages.Error404(w) 201 - return nil 392 + return 202 393 } 203 394 204 - feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) 395 + feed, err := s.getProfileFeed(r.Context(), &ident) 205 396 if err != nil { 206 397 s.pages.Error500(w) 207 - return nil 398 + return 208 399 } 209 400 210 - return feed 211 - } 212 - 213 - func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 214 - feed := s.feedFromRequest(w, r) 215 401 if feed == nil { 216 402 return 217 403 } ··· 226 412 w.Write([]byte(atom)) 227 413 } 228 414 229 - func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { 230 - timeline, err := db.MakeProfileTimeline(s.db, did) 415 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 416 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 231 417 if err != nil { 232 418 return nil, err 233 419 } 234 420 235 421 author := &feeds.Author{ 236 - Name: fmt.Sprintf("@%s", handle), 422 + Name: fmt.Sprintf("@%s", id.Handle), 237 423 } 238 - feed := &feeds.Feed{ 239 - Title: fmt.Sprintf("timeline feed for %s", author.Name), 240 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, 424 + 425 + feed := feeds.Feed{ 426 + Title: fmt.Sprintf("%s's timeline", author.Name), 427 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 241 428 Items: make([]*feeds.Item, 0), 242 429 Updated: time.UnixMilli(0), 243 430 Author: author, 244 431 } 432 + 245 433 for _, byMonth := range timeline.ByMonth { 246 - for _, pull := range byMonth.PullEvents.Items { 247 - owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 248 - if err != nil { 249 - return nil, err 250 - } 251 - feed.Items = append(feed.Items, &feeds.Item{ 252 - Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 253 - 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"}, 254 - Created: pull.Created, 255 - Author: author, 256 - }) 257 - for _, submission := range pull.Submissions { 258 - feed.Items = append(feed.Items, &feeds.Item{ 259 - Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), 260 - 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"}, 261 - Created: submission.Created, 262 - Author: author, 263 - }) 264 - } 434 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 435 + return nil, err 265 436 } 266 - for _, issue := range byMonth.IssueEvents.Items { 267 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 268 - if err != nil { 269 - return nil, err 270 - } 271 - feed.Items = append(feed.Items, &feeds.Item{ 272 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 273 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 274 - Created: issue.Created, 275 - Author: author, 276 - }) 437 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 438 + return nil, err 277 439 } 278 - for _, repo := range byMonth.RepoEvents { 279 - var title string 280 - if repo.Source != nil { 281 - id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 282 - if err != nil { 283 - return nil, err 284 - } 285 - title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) 286 - } else { 287 - title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 288 - } 289 - feed.Items = append(feed.Items, &feeds.Item{ 290 - Title: title, 291 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, 292 - Created: repo.Repo.Created, 293 - Author: author, 294 - }) 440 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 441 + return nil, err 295 442 } 296 443 } 444 + 297 445 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 298 446 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 299 447 }) 448 + 300 449 if len(feed.Items) > 0 { 301 450 feed.Updated = feed.Items[0].Created 302 451 } 303 452 304 - return feed, nil 453 + return &feed, nil 454 + } 455 + 456 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 457 + for _, pull := range pulls { 458 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 459 + if err != nil { 460 + return err 461 + } 462 + 463 + // Add pull request creation item 464 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 465 + } 466 + return nil 467 + } 468 + 469 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 470 + for _, issue := range issues { 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 + if err != nil { 473 + return err 474 + } 475 + 476 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 477 + } 478 + return nil 479 + } 480 + 481 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 482 + for _, repo := range repos { 483 + item, err := s.createRepoItem(ctx, repo, author) 484 + if err != nil { 485 + return err 486 + } 487 + feed.Items = append(feed.Items, item) 488 + } 489 + return nil 490 + } 491 + 492 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 + return &feeds.Item{ 494 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 495 + 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"}, 496 + Created: pull.Created, 497 + Author: author, 498 + } 499 + } 500 + 501 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 + return &feeds.Item{ 503 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 + 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"}, 505 + Created: issue.Created, 506 + Author: author, 507 + } 508 + } 509 + 510 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 511 + var title string 512 + if repo.Source != nil { 513 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 514 + if err != nil { 515 + return nil, err 516 + } 517 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 518 + } else { 519 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 520 + } 521 + 522 + return &feeds.Item{ 523 + Title: title, 524 + 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 525 + Created: repo.Repo.Created, 526 + Author: author, 527 + }, nil 305 528 } 306 529 307 530 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 479 702 log.Printf("getting profile data for %s: %s", user.Did, err) 480 703 } 481 704 482 - repos, err := db.GetAllReposByDid(s.db, user.Did) 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 483 706 if err != nil { 484 707 log.Printf("getting repos for %s: %s", user.Did, err) 485 708 }
+21 -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 ··· 72 78 r.Get("/", s.Profile) 73 79 r.Get("/feed.atom", s.AtomFeedPage) 74 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 + }) 86 + 75 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 76 88 r.Use(mw.GoImport()) 77 - 78 89 r.Mount("/", s.RepoRouter(mw)) 79 90 r.Mount("/issues", s.IssuesRouter(mw)) 80 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 100 111 101 112 r.Handle("/static/*", s.pages.Static()) 102 113 103 - 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) 104 117 105 118 r.Route("/repo", func(r chi.Router) { 106 119 r.Route("/new", func(r chi.Router) { ··· 136 149 137 150 r.Mount("/settings", s.SettingsRouter()) 138 151 r.Mount("/strings", s.StringsRouter(mw)) 139 - r.Mount("/knots", s.KnotsRouter(mw)) 152 + r.Mount("/knots", s.KnotsRouter()) 140 153 r.Mount("/spindles", s.SpindlesRouter()) 141 154 r.Mount("/signup", s.SignupRouter()) 142 155 r.Mount("/", s.OAuthRouter()) ··· 184 197 return spindles.Router() 185 198 } 186 199 187 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 200 + func (s *State) KnotsRouter() http.Handler { 188 201 logger := log.New("knots") 189 202 190 203 knots := &knots.Knots{ ··· 198 211 Logger: logger, 199 212 } 200 213 201 - return knots.Router(mw) 214 + return knots.Router() 202 215 } 203 216 204 217 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { ··· 219 232 } 220 233 221 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 222 - 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) 223 236 return issues.Router(mw) 224 237 } 225 238
+199 -43
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) { ··· 68 75 } 69 76 70 77 pgs := pages.NewPages(config, res) 71 - 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 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 225 + } 226 + 183 227 s.pages.Timeline(w, pages.TimelineParams{ 184 228 LoggedInUser: user, 185 229 Timeline: timeline, 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, 265 + }) 266 + } 267 + 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, 186 287 }) 187 288 } 188 289 ··· 214 315 215 316 for _, k := range pubKeys { 216 317 key := strings.TrimRight(k.Key, "\n") 217 - w.Write([]byte(fmt.Sprintln(key))) 318 + fmt.Fprintln(w, key) 218 319 } 219 320 } 220 321 ··· 250 351 return nil 251 352 } 252 353 354 + func stripGitExt(name string) string { 355 + return strings.TrimSuffix(name, ".git") 356 + } 357 + 253 358 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 254 359 switch r.Method { 255 360 case http.MethodGet: ··· 266 371 }) 267 372 268 373 case http.MethodPost: 374 + l := s.logger.With("handler", "NewRepo") 375 + 269 376 user := s.oauth.GetUser(r) 377 + l = l.With("did", user.Did) 378 + l = l.With("handle", user.Handle) 270 379 380 + // form validation 271 381 domain := r.FormValue("domain") 272 382 if domain == "" { 273 383 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 274 384 return 275 385 } 386 + l = l.With("knot", domain) 276 387 277 388 repoName := r.FormValue("name") 278 389 if repoName == "" { ··· 284 395 s.pages.Notice(w, "repo", err.Error()) 285 396 return 286 397 } 398 + repoName = stripGitExt(repoName) 399 + l = l.With("repoName", repoName) 287 400 288 401 defaultBranch := r.FormValue("branch") 289 402 if defaultBranch == "" { 290 403 defaultBranch = "main" 291 404 } 405 + l = l.With("defaultBranch", defaultBranch) 292 406 293 407 description := r.FormValue("description") 294 408 409 + // ACL validation 295 410 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 296 411 if err != nil || !ok { 412 + l.Info("unauthorized") 297 413 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 298 414 return 299 415 } 300 416 417 + // Check for existing repos 301 418 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 302 419 if err == nil && existingRepo != nil { 303 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 304 - return 305 - } 306 - 307 - secret, err := db.GetRegistrationKey(s.db, domain) 308 - if err != nil { 309 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 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)) 310 422 return 311 423 } 312 424 313 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 314 - if err != nil { 315 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 316 - return 317 - } 318 - 425 + // create atproto record for this repo 319 426 rkey := tid.TID() 320 427 repo := &db.Repo{ 321 428 Did: user.Did, ··· 327 434 328 435 xrpcClient, err := s.oauth.AuthorizedClient(r) 329 436 if err != nil { 437 + l.Info("PDS write failed", "err", err) 330 438 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 331 439 return 332 440 } ··· 345 453 }}, 346 454 }) 347 455 if err != nil { 348 - log.Printf("failed to create record: %s", err) 456 + l.Info("PDS write failed", "err", err) 349 457 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 350 458 return 351 459 } 352 - log.Println("created repo record: ", atresp.Uri) 460 + 461 + aturi := atresp.Uri 462 + l = l.With("aturi", aturi) 463 + l.Info("wrote to PDS") 353 464 354 465 tx, err := s.db.BeginTx(r.Context(), nil) 355 466 if err != nil { 356 - log.Println(err) 467 + l.Info("txn failed", "err", err) 357 468 s.pages.Notice(w, "repo", "Failed to save repository information.") 358 469 return 359 470 } 360 - defer func() { 361 - tx.Rollback() 362 - err = s.enforcer.E.LoadPolicy() 363 - if err != nil { 364 - 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 365 484 } 366 - }() 485 + 486 + if errs := errors.Join(err1, err2, err3); errs != nil { 487 + l.Error("failed to rollback changes", "errs", errs) 488 + return 489 + } 490 + } 491 + defer rollback() 367 492 368 - 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 + ) 369 499 if err != nil { 370 - 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.") 371 502 return 372 503 } 373 504 374 - switch resp.StatusCode { 375 - case http.StatusConflict: 376 - 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()) 377 515 return 378 - case http.StatusInternalServerError: 379 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 380 - case http.StatusNoContent: 381 - // continue 382 516 } 383 517 384 - repo.AtUri = atresp.Uri 385 518 err = db.AddRepo(tx, repo) 386 519 if err != nil { 387 - log.Println(err) 520 + l.Error("db write failed", "err", err) 388 521 s.pages.Notice(w, "repo", "Failed to save repository information.") 389 522 return 390 523 } ··· 393 526 p, _ := securejoin.SecureJoin(user.Did, repoName) 394 527 err = s.enforcer.AddRepo(user.Did, domain, p) 395 528 if err != nil { 396 - log.Println(err) 529 + l.Error("acl setup failed", "err", err) 397 530 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 398 531 return 399 532 } 400 533 401 534 err = tx.Commit() 402 535 if err != nil { 403 - log.Println("failed to commit changes", err) 536 + l.Error("txn commit failed", "err", err) 404 537 http.Error(w, err.Error(), http.StatusInternalServerError) 405 538 return 406 539 } 407 540 408 541 err = s.enforcer.E.SavePolicy() 409 542 if err != nil { 410 - log.Println("failed to update ACLs", err) 543 + l.Error("acl save failed", "err", err) 411 544 http.Error(w, err.Error(), http.StatusInternalServerError) 412 545 return 413 546 } 414 547 415 - s.notifier.NewRepo(r.Context(), repo) 548 + // reset the ATURI because the transaction completed successfully 549 + aturi = "" 416 550 551 + s.notifier.NewRepo(r.Context(), repo) 417 552 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 418 - return 419 553 } 420 554 } 555 + 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 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 576 + }
+31 -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" 14 12 "tangled.sh/tangled.sh/core/appview/config" 15 13 "tangled.sh/tangled.sh/core/appview/db" 16 14 "tangled.sh/tangled.sh/core/appview/middleware" 15 + "tangled.sh/tangled.sh/core/appview/notify" 17 16 "tangled.sh/tangled.sh/core/appview/oauth" 18 17 "tangled.sh/tangled.sh/core/appview/pages" 19 18 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 38 37 IdResolver *idresolver.Resolver 39 38 Logger *slog.Logger 40 39 Knotstream *eventconsumer.Consumer 40 + Notifier notify.Notifier 41 41 } 42 42 43 43 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 44 44 r := chi.NewRouter() 45 + 46 + r. 47 + Get("/", s.timeline) 45 48 46 49 r. 47 50 With(mw.ResolveIdent()). ··· 70 73 return r 71 74 } 72 75 76 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 77 + l := s.Logger.With("handler", "timeline") 78 + 79 + strings, err := db.GetStrings(s.Db, 50) 80 + if err != nil { 81 + l.Error("failed to fetch string", "err", err) 82 + w.WriteHeader(http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 87 + LoggedInUser: s.OAuth.GetUser(r), 88 + Strings: strings, 89 + }) 90 + } 91 + 73 92 func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 93 l := s.Logger.With("handler", "contents") 75 94 ··· 91 110 92 111 strings, err := db.GetStrings( 93 112 s.Db, 113 + 0, 94 114 db.FilterEq("did", id.DID), 95 115 db.FilterEq("rkey", rkey), 96 116 ) ··· 142 162 } 143 163 144 164 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.GetFollowerFollowingCount(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 - }) 165 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 202 166 } 203 167 204 168 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { ··· 225 189 // get the string currently being edited 226 190 all, err := db.GetStrings( 227 191 s.Db, 192 + 0, 228 193 db.FilterEq("did", id.DID), 229 194 db.FilterEq("rkey", rkey), 230 195 ) ··· 266 231 fail("Empty filename.", nil) 267 232 return 268 233 } 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 234 275 235 content := r.FormValue("content") 276 236 if content == "" { ··· 326 286 return 327 287 } 328 288 289 + s.Notifier.EditString(r.Context(), &entry) 290 + 329 291 // if that went okay, redir to the string 330 292 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 331 293 } ··· 351 313 filename := r.FormValue("filename") 352 314 if filename == "" { 353 315 fail("Empty filename.", nil) 354 - return 355 - } 356 - if !strings.Contains(filename, ".") { 357 - // TODO: make this a htmx form validation 358 - fail("No extension provided for filename.", nil) 359 316 return 360 317 } 361 318 ··· 405 362 return 406 363 } 407 364 365 + s.Notifier.NewString(r.Context(), &string) 366 + 408 367 // successful 409 368 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 410 369 } ··· 434 393 } 435 394 436 395 if user.Did != id.DID.String() { 437 - fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 396 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 397 return 439 398 } 440 399 ··· 446 405 fail("Failed to delete string.", err) 447 406 return 448 407 } 408 + 409 + s.Notifier.DeleteString(r.Context(), user.Did, rkey) 449 410 450 411 s.Pages.HxRedirect(w, "/strings/"+user.Handle) 451 412 }
+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{},
+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 }
+44
contrib/Tiltfile
··· 1 + common_env = { 2 + "TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""), 3 + "TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""), 4 + "TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"), 5 + "TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"), 6 + } 7 + 8 + nix_globs = ["nix/**", "flake.nix", "flake.lock"] 9 + 10 + local_resource( 11 + name="appview", 12 + serve_cmd="nix run .#watch-appview", 13 + serve_dir="..", 14 + deps=nix_globs, 15 + env=common_env, 16 + allow_parallel=True, 17 + ) 18 + 19 + local_resource( 20 + name="tailwind", 21 + serve_cmd="nix run .#watch-tailwind", 22 + serve_dir="..", 23 + deps=nix_globs, 24 + env=common_env, 25 + allow_parallel=True, 26 + ) 27 + 28 + local_resource( 29 + name="redis", 30 + serve_cmd="redis-server", 31 + serve_dir="..", 32 + deps=nix_globs, 33 + env=common_env, 34 + allow_parallel=True, 35 + ) 36 + 37 + local_resource( 38 + name="vm", 39 + serve_cmd="nix run --impure .#vm", 40 + serve_dir="..", 41 + deps=nix_globs, 42 + env=common_env, 43 + allow_parallel=True, 44 + )
+16
default.nix
··· 1 + # Default setup from https://git.lix.systems/lix-project/flake-compat 2 + let 3 + lockFile = builtins.fromJSON (builtins.readFile ./flake.lock); 4 + flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat}; 5 + flake-compat = builtins.fetchTarball { 6 + inherit (flake-compat-node.locked) url; 7 + sha256 = flake-compat-node.locked.narHash; 8 + }; 9 + 10 + flake = ( 11 + import flake-compat { 12 + src = ./.; 13 + } 14 + ); 15 + in 16 + flake.defaultNix
+9 -3
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
+63 -22
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 create a knot with hostname `localhost:6000`. This will 60 - generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 - ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 - don't lose it. 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). 63 96 64 - You will also need to set the `$TANGLED_VM_SPINDLE_OWNER` 65 - variable to some value. If you don't want to [set up a 66 - spindle](#running-a-spindle), you can use any placeholder 67 - value. 97 + </details> 68 98 69 - You can now start a lightweight NixOS VM 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: 70 103 71 104 ```bash 72 105 nix run --impure .#vm ··· 75 108 ``` 76 109 77 110 This starts a knot on port 6000, a spindle on port 6555 78 - with `ssh` exposed on port 2222. You can push repositories 79 - to this VM with this ssh config block on your main machine: 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: 80 120 81 121 ```bash 82 122 Host nixos-shell ··· 93 133 git push local-dev main 94 134 ``` 95 135 96 - ## running a spindle 136 + ### running a spindle 97 137 98 - You will need to find out your DID by entering your login handle into 99 - <https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID. 100 - 101 - The above VM should already be running a spindle on `localhost:6555`. 102 - You can head to the spindle dashboard on `http://localhost:3000/spindles`, 103 - and register a spindle with hostname `localhost:6555`. It should instantly 104 - be verified. You can then configure each repository to use this spindle 105 - and run CI jobs. 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. 106 142 107 143 Of interest when debugging spindles: 108 144 ··· 119 155 # litecli has a nicer REPL interface: 120 156 litecli /var/lib/spindle/spindle.db 121 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`.
+7 -5
docs/knot-hosting.md
··· 73 73 ``` 74 74 75 75 Create `/home/git/.knot.env` with the following, updating the values as 76 - necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 - [/knots](https://tangled.sh/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. 78 78 79 79 ``` 80 80 KNOT_REPO_SCAN_PATH=/home/git 81 81 KNOT_SERVER_HOSTNAME=knot.example.com 82 82 APPVIEW_ENDPOINT=https://tangled.sh 83 - KNOT_SERVER_SECRET=secret 83 + KNOT_SERVER_OWNER=did:plc:foobar 84 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 85 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 86 86 ``` ··· 128 128 Remember to use Let's Encrypt or similar to procure a certificate for your 129 129 knot domain. 130 130 131 - You should now have a running knot server! You can finalize your registration by hitting the 132 - `initialize` button on the [/knots](https://tangled.sh/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. 133 135 134 136 ### custom paths 135 137
+60
docs/migrations.md
··· 1 + # Migrations 2 + 3 + This document is laid out in reverse-chronological order. 4 + Newer migration guides are listed first, and older guides 5 + are further down the page. 6 + 7 + ## Upgrading from v1.8.x 8 + 9 + After v1.8.2, the HTTP API for knot and spindles have been 10 + deprecated and replaced with XRPC. Repositories on outdated 11 + knots will not be viewable from the appview. Upgrading is 12 + straightforward however. 13 + 14 + For knots: 15 + 16 + - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.sh/knots) and 18 + hit the "retry" button to verify your knot 19 + 20 + For spindles: 21 + 22 + - Upgrade to latest tag (v1.9.0 or above) 23 + - Head to the [spindle 24 + dashboard](https://tangled.sh/spindles) and hit the 25 + "retry" button to verify your spindle 26 + 27 + ## Upgrading from v1.7.x 28 + 29 + After v1.7.0, knot secrets have been deprecated. You no 30 + longer need a secret from the appview to run a knot. All 31 + authorized commands to knots are managed via [Inter-Service 32 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 + Knots will be read-only until upgraded. 34 + 35 + Upgrading is quite easy, in essence: 36 + 37 + - `KNOT_SERVER_SECRET` is no more, you can remove this 38 + environment variable entirely 39 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 + your DID. You can find your DID in the 41 + [settings](https://tangled.sh/settings) page. 42 + - Restart your knot once you have replaced the environment 43 + variable 44 + - Head to the [knot dashboard](https://tangled.sh/knots) and 45 + hit the "retry" button to verify your knot. This simply 46 + writes a `sh.tangled.knot` record to your PDS. 47 + 48 + If you use the nix module, simply bump the flake to the 49 + latest revision, and change your config block like so: 50 + 51 + ```diff 52 + services.tangled-knot = { 53 + enable = true; 54 + server = { 55 + - secretFile = /path/to/secret; 56 + + owner = "did:plc:foo"; 57 + }; 58 + }; 59 + ``` 60 +
+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 }
+15
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "flake-compat": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1751685974, 7 + "narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=", 8 + "rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1", 9 + "type": "tarball", 10 + "url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz" 15 + } 16 + }, 3 17 "flake-utils": { 4 18 "inputs": { 5 19 "systems": "systems" ··· 136 150 }, 137 151 "root": { 138 152 "inputs": { 153 + "flake-compat": "flake-compat", 139 154 "gomod2nix": "gomod2nix", 140 155 "htmx-src": "htmx-src", 141 156 "htmx-ws-src": "htmx-ws-src",
+40 -3
flake.nix
··· 7 7 url = "github:nix-community/gomod2nix"; 8 8 inputs.nixpkgs.follows = "nixpkgs"; 9 9 }; 10 + flake-compat = { 11 + url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"; 12 + flake = false; 13 + }; 10 14 indigo = { 11 15 url = "github:oppiliappan/indigo"; 12 16 flake = false; ··· 50 54 inter-fonts-src, 51 55 sqlite-lib-src, 52 56 ibm-plex-mono-src, 57 + ... 53 58 }: let 54 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 106 111 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 107 112 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 108 113 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 114 + 115 + treefmt-wrapper = pkgs.treefmt.withConfig { 116 + settings.formatter = { 117 + alejandra = { 118 + command = pkgs.lib.getExe pkgs.alejandra; 119 + includes = ["*.nix"]; 120 + }; 121 + 122 + gofmt = { 123 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 124 + options = ["-w"]; 125 + includes = ["*.go"]; 126 + }; 127 + 128 + # prettier = let 129 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 130 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 131 + # ''; 132 + # in { 133 + # command = wrapper; 134 + # options = ["-w"]; 135 + # includes = ["*.html"]; 136 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 137 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 138 + # }; 139 + }; 140 + }; 109 141 }); 110 142 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 111 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 112 143 devShells = forAllSystems (system: let 113 144 pkgs = nixpkgsFor.${system}; 114 145 packages' = self.packages.${system}; ··· 120 151 nativeBuildInputs = [ 121 152 pkgs.go 122 153 pkgs.air 154 + pkgs.tilt 123 155 pkgs.gopls 124 156 pkgs.httpie 125 157 pkgs.litecli ··· 129 161 pkgs.redis 130 162 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 131 163 packages'.lexgen 164 + packages'.treefmt-wrapper 132 165 ]; 133 166 shellHook = '' 134 167 mkdir -p appview/pages/static ··· 155 188 tailwind-watcher = 156 189 pkgs.writeShellScriptBin "run" 157 190 '' 158 - ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 191 + ${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css 159 192 ''; 160 193 in { 194 + fmt = { 195 + type = "app"; 196 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 197 + }; 161 198 watch-appview = { 162 199 type = "app"; 163 200 program = toString (pkgs.writeShellScript "watch-appview" '' ··· 221 258 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 222 259 cd "$rootDir" 223 260 224 - rm api/tangled/* 261 + rm -f api/tangled/* 225 262 lexgen --build-file lexicon-build-config.json lexicons 226 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 227 264 ${pkgs.gotools}/bin/goimports -w api/tangled/*
+5 -2
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 ··· 88 91 github.com/golang/mock v1.6.0 // indirect 89 92 github.com/google/go-querystring v1.1.0 // indirect 90 93 github.com/gorilla/css v1.0.1 // indirect 91 - github.com/gorilla/feeds v1.2.0 // indirect 92 94 github.com/gorilla/securecookie v1.1.2 // indirect 93 95 github.com/hashicorp/errwrap v1.1.0 // indirect 94 96 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect ··· 153 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 154 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 155 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 156 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 157 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 158 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+10 -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= ··· 425 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 426 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 427 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= 428 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 429 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 430 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 431 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 432 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 433 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= 434 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 435 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 436 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+75 -13
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 40 + font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 32 48 font-weight: normal; 33 49 font-style: italic; 34 50 font-display: swap; 35 51 } 36 52 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 65 + font-style: italic; 66 + font-display: swap; 67 + } 68 + 37 69 ::selection { 38 70 @apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white; 39 71 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 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; ··· 72 103 } 73 104 74 105 code { 75 - @apply px-1 font-mono rounded bg-gray-100 dark:bg-gray-700; 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 76 107 } 77 108 } 78 109 ··· 102 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 103 134 } 104 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 + 105 172 .prose img { 106 173 display: inline; 107 174 margin: 0; ··· 134 201 /* PreWrapper */ 135 202 .chroma { 136 203 color: #4c4f69; 137 - background-color: #eff1f5; 138 204 } 139 205 /* Error */ 140 206 .chroma .err { ··· 162 228 } 163 229 /* LineHighlight */ 164 230 .chroma .hl { 165 - background-color: #bcc0cc; 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 166 232 } 233 + 167 234 /* LineNumbersTable */ 168 235 .chroma .lnt { 169 236 white-space: pre; ··· 471 538 /* PreWrapper */ 472 539 .chroma { 473 540 color: #cad3f5; 474 - background-color: #24273a; 475 541 } 476 542 /* Error */ 477 543 .chroma .err { ··· 799 865 text-decoration: underline; 800 866 } 801 867 } 802 - 803 - .chroma .line:has(.ln:target) { 804 - @apply bg-amber-400/30 dark:bg-amber-500/20; 805 - }
+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
+4 -1
knotserver/git/language.go
··· 3 3 import ( 4 4 "context" 5 5 "path" 6 + "strings" 6 7 7 8 "github.com/go-enry/go-enry/v2" 8 9 "github.com/go-git/go-git/v5/plumbing/object" ··· 20 21 return nil 21 22 } 22 23 23 - if enry.IsGenerated(filepath, content) { 24 + if enry.IsGenerated(filepath, content) || 25 + enry.IsBinary(content) || 26 + strings.HasSuffix(filepath, "bun.lock") { 24 27 return nil 25 28 } 26 29
+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
+9 -10
knotserver/git/post_receive.go
··· 145 145 } 146 146 147 147 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 148 - var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem 148 + var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount 149 149 for e, v := range m.CommitCount.ByEmail { 150 - byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{ 150 + byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{ 151 151 Email: e, 152 152 Count: int64(v), 153 153 }) 154 154 } 155 155 156 - var langs []*tangled.GitRefUpdate_Pair 156 + var langs []*tangled.GitRefUpdate_IndividualLanguageSize 157 157 for lang, size := range m.LangBreakdown { 158 - langs = append(langs, &tangled.GitRefUpdate_Pair{ 158 + langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{ 159 159 Lang: lang, 160 160 Size: size, 161 161 }) 162 162 } 163 - langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 164 - Inputs: langs, 165 - } 166 163 167 164 return tangled.GitRefUpdate_Meta{ 168 - CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 165 + CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{ 169 166 ByEmail: byEmail, 170 167 }, 171 - IsDefaultRef: m.IsDefaultRef, 172 - LangBreakdown: langBreakdown, 168 + IsDefaultRef: m.IsDefaultRef, 169 + LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{ 170 + Inputs: langs, 171 + }, 173 172 } 174 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 }
+15 -37
knotserver/internal.go
··· 47 47 } 48 48 49 49 w.WriteHeader(http.StatusNoContent) 50 - return 51 50 } 52 51 53 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 63 62 data = append(data, j) 64 63 } 65 64 writeJSON(w, data) 66 - return 67 65 } 68 66 69 67 type PushOptions struct { ··· 200 198 return err 201 199 } 202 200 203 - pipelineParseErrors := []string{} 204 - 205 - var pipeline workflow.Pipeline 201 + var pipeline workflow.RawPipeline 206 202 for _, e := range workflowDir { 207 203 if !e.IsFile { 208 204 continue ··· 214 210 continue 215 211 } 216 212 217 - wf, err := workflow.FromFile(e.Name, contents) 218 - if err != nil { 219 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 220 - pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 221 - continue 222 - } 223 - 224 - pipeline = append(pipeline, wf) 213 + pipeline = append(pipeline, workflow.RawWorkflow{ 214 + Name: e.Name, 215 + Contents: contents, 216 + }) 225 217 } 226 218 227 219 trigger := tangled.Pipeline_PushTriggerData{ ··· 242 234 }, 243 235 } 244 236 245 - cp := compiler.Compile(pipeline) 237 + cp := compiler.Compile(compiler.Parse(pipeline)) 246 238 eventJson, err := json.Marshal(cp) 247 239 if err != nil { 248 240 return err 249 241 } 250 242 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 251 247 if pushOptions.verboseCi { 252 - hasDiagnostics := false 253 - if len(pipelineParseErrors) > 0 { 254 - hasDiagnostics = true 255 - *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 256 - for _, error := range pipelineParseErrors { 257 - *clientMsgs = append(*clientMsgs, error) 258 - } 248 + if compiler.Diagnostics.IsEmpty() { 249 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 259 250 } 260 - if len(compiler.Diagnostics.Errors) > 0 { 261 - hasDiagnostics = true 262 - *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 263 - for _, error := range compiler.Diagnostics.Errors { 264 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 265 - } 266 - } 267 - if len(compiler.Diagnostics.Warnings) > 0 { 268 - hasDiagnostics = true 269 - *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 270 - for _, warning := range compiler.Diagnostics.Warnings { 271 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 272 - } 273 - } 274 - if !hasDiagnostics { 275 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 251 + 252 + for _, w := range compiler.Diagnostics.Warnings { 253 + *clientMsgs = append(*clientMsgs, w.String()) 276 254 } 277 255 } 278 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 + }
-1356
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 - unescapedRef, err := url.PathUnescape(ref) 365 - if err != nil { 366 - notFound(w) 367 - return 368 - } 369 - 370 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 371 - 372 - // This allows the browser to use a proper name for the file when 373 - // downloading 374 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 375 - setContentDisposition(w, filename) 376 - setGZipMIME(w) 377 - 378 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 379 - gr, err := git.Open(path, unescapedRef) 380 - if err != nil { 381 - notFound(w) 382 - return 383 - } 384 - 385 - gw := gzip.NewWriter(w) 386 - defer gw.Close() 387 - 388 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 389 - err = gr.WriteTar(gw, prefix) 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("writing tar file", "error", err.Error()) 394 - return 395 - } 396 - 397 - err = gw.Flush() 398 - if err != nil { 399 - // once we start writing to the body we can't report error anymore 400 - // so we are only left with printing the error. 401 - l.Error("flushing?", "error", err.Error()) 402 - return 403 - } 404 - } 405 - 406 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 407 - ref := chi.URLParam(r, "ref") 408 - ref, _ = url.PathUnescape(ref) 409 - 410 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 411 - 412 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 413 - 414 - gr, err := git.Open(path, ref) 415 - if err != nil { 416 - notFound(w) 417 - return 418 - } 419 - 420 - // Get page parameters 421 - page := 1 422 - pageSize := 30 423 - 424 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 425 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 426 - page = p 427 - } 428 - } 429 - 430 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 431 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 432 - pageSize = ps 433 - } 434 - } 435 - 436 - // convert to offset/limit 437 - offset := (page - 1) * pageSize 438 - limit := pageSize 439 - 440 - commits, err := gr.Commits(offset, limit) 441 - if err != nil { 442 - writeError(w, err.Error(), http.StatusInternalServerError) 443 - l.Error("fetching commits", "error", err.Error()) 444 - return 445 - } 446 - 447 - total := len(commits) 448 - 449 - resp := types.RepoLogResponse{ 450 - Commits: commits, 451 - Ref: ref, 452 - Description: getDescription(path), 453 - Log: true, 454 - Total: total, 455 - Page: page, 456 - PerPage: pageSize, 457 - } 458 - 459 - writeJSON(w, resp) 460 - return 461 - } 462 - 463 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 464 - ref := chi.URLParam(r, "ref") 465 - ref, _ = url.PathUnescape(ref) 466 - 467 - l := h.l.With("handler", "Diff", "ref", ref) 468 - 469 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 470 - gr, err := git.Open(path, ref) 471 - if err != nil { 472 - notFound(w) 473 - return 474 - } 475 - 476 - diff, err := gr.Diff() 477 - if err != nil { 478 - writeError(w, err.Error(), http.StatusInternalServerError) 479 - l.Error("getting diff", "error", err.Error()) 480 - return 481 - } 482 - 483 - resp := types.RepoCommitResponse{ 484 - Ref: ref, 485 - Diff: diff, 486 - } 487 - 488 - writeJSON(w, resp) 489 - return 490 - } 491 - 492 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 493 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 494 - l := h.l.With("handler", "Refs") 495 - 496 - gr, err := git.Open(path, "") 497 - if err != nil { 498 - notFound(w) 499 - return 500 - } 501 - 502 - tags, err := gr.Tags() 503 - if err != nil { 504 - // Non-fatal, we *should* have at least one branch to show. 505 - l.Warn("getting tags", "error", err.Error()) 506 - } 507 - 508 - rtags := []*types.TagReference{} 509 - for _, tag := range tags { 510 - var target *object.Tag 511 - if tag.Target != plumbing.ZeroHash { 512 - target = &tag 513 - } 514 - tr := types.TagReference{ 515 - Tag: target, 516 - } 517 - 518 - tr.Reference = types.Reference{ 519 - Name: tag.Name, 520 - Hash: tag.Hash.String(), 521 - } 522 - 523 - if tag.Message != "" { 524 - tr.Message = tag.Message 525 - } 526 - 527 - rtags = append(rtags, &tr) 528 - } 529 - 530 - resp := types.RepoTagsResponse{ 531 - Tags: rtags, 532 - } 533 - 534 - writeJSON(w, resp) 535 - return 536 - } 537 - 538 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 539 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 540 - 541 - gr, err := git.PlainOpen(path) 542 - if err != nil { 543 - notFound(w) 544 - return 545 - } 546 - 547 - branches, _ := gr.Branches() 548 - 549 - resp := types.RepoBranchesResponse{ 550 - Branches: branches, 551 - } 552 - 553 - writeJSON(w, resp) 554 - return 555 - } 556 - 557 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 558 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 559 - branchName := chi.URLParam(r, "branch") 560 - branchName, _ = url.PathUnescape(branchName) 561 - 562 - l := h.l.With("handler", "Branch") 563 - 564 - gr, err := git.PlainOpen(path) 565 - if err != nil { 566 - notFound(w) 567 - return 568 - } 569 - 570 - ref, err := gr.Branch(branchName) 571 - if err != nil { 572 - l.Error("getting branch", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 - } 576 - 577 - commit, err := gr.Commit(ref.Hash()) 578 - if err != nil { 579 - l.Error("getting commit object", "error", err.Error()) 580 - writeError(w, err.Error(), http.StatusInternalServerError) 581 - return 582 - } 583 - 584 - defaultBranch, err := gr.FindMainBranch() 585 - isDefault := false 586 - if err != nil { 587 - l.Error("getting default branch", "error", err.Error()) 588 - // do not quit though 589 - } else if defaultBranch == branchName { 590 - isDefault = true 591 - } 592 - 593 - resp := types.RepoBranchResponse{ 594 - Branch: types.Branch{ 595 - Reference: types.Reference{ 596 - Name: ref.Name().Short(), 597 - Hash: ref.Hash().String(), 598 - }, 599 - Commit: commit, 600 - IsDefault: isDefault, 601 - }, 602 - } 603 - 604 - writeJSON(w, resp) 605 - return 606 - } 607 - 608 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 609 - l := h.l.With("handler", "Keys") 610 - 611 - switch r.Method { 612 - case http.MethodGet: 613 - keys, err := h.db.GetAllPublicKeys() 614 - if err != nil { 615 - writeError(w, err.Error(), http.StatusInternalServerError) 616 - l.Error("getting public keys", "error", err.Error()) 617 - return 618 - } 619 - 620 - data := make([]map[string]any, 0) 621 - for _, key := range keys { 622 - j := key.JSON() 623 - data = append(data, j) 624 - } 625 - writeJSON(w, data) 626 - return 627 - 628 - case http.MethodPut: 629 - pk := db.PublicKey{} 630 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 631 - writeError(w, "invalid request body", http.StatusBadRequest) 632 - return 633 - } 634 - 635 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 636 - if err != nil { 637 - writeError(w, "invalid pubkey", http.StatusBadRequest) 638 - } 639 - 640 - if err := h.db.AddPublicKey(pk); err != nil { 641 - writeError(w, err.Error(), http.StatusInternalServerError) 642 - l.Error("adding public key", "error", err.Error()) 643 - return 644 - } 645 - 646 - w.WriteHeader(http.StatusNoContent) 647 - return 648 - } 649 - } 650 - 651 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 652 - l := h.l.With("handler", "NewRepo") 653 - 654 - data := struct { 655 - Did string `json:"did"` 656 - Name string `json:"name"` 657 - DefaultBranch string `json:"default_branch,omitempty"` 658 - }{} 659 - 660 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 661 - writeError(w, "invalid request body", http.StatusBadRequest) 662 - return 663 - } 664 - 665 - if data.DefaultBranch == "" { 666 - data.DefaultBranch = h.c.Repo.MainBranch 667 - } 668 - 669 - did := data.Did 670 - name := data.Name 671 - defaultBranch := data.DefaultBranch 672 - 673 - if err := validateRepoName(name); err != nil { 674 - l.Error("creating repo", "error", err.Error()) 675 - writeError(w, err.Error(), http.StatusBadRequest) 676 - return 677 - } 678 - 679 - relativeRepoPath := filepath.Join(did, name) 680 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 681 - err := git.InitBare(repoPath, defaultBranch) 682 - if err != nil { 683 - l.Error("initializing bare repo", "error", err.Error()) 684 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 685 - writeError(w, "That repo already exists!", http.StatusConflict) 686 - return 687 - } else { 688 - writeError(w, err.Error(), http.StatusInternalServerError) 689 - return 690 - } 691 - } 692 - 693 - // add perms for this user to access the repo 694 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 695 - if err != nil { 696 - l.Error("adding repo permissions", "error", err.Error()) 697 - writeError(w, err.Error(), http.StatusInternalServerError) 698 - return 699 - } 700 - 701 - hook.SetupRepo( 702 - hook.Config( 703 - hook.WithScanPath(h.c.Repo.ScanPath), 704 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 705 - ), 706 - repoPath, 707 - ) 708 - 709 - w.WriteHeader(http.StatusNoContent) 710 - } 711 - 712 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 713 - l := h.l.With("handler", "RepoForkSync") 714 - 715 - data := struct { 716 - Did string `json:"did"` 717 - Source string `json:"source"` 718 - Name string `json:"name,omitempty"` 719 - HiddenRef string `json:"hiddenref"` 720 - }{} 721 - 722 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 723 - writeError(w, "invalid request body", http.StatusBadRequest) 724 - return 725 - } 726 - 727 - did := data.Did 728 - source := data.Source 729 - 730 - if did == "" || source == "" { 731 - l.Error("invalid request body, empty did or name") 732 - w.WriteHeader(http.StatusBadRequest) 733 - return 734 - } 735 - 736 - var name string 737 - if data.Name != "" { 738 - name = data.Name 739 - } else { 740 - name = filepath.Base(source) 741 - } 742 - 743 - branch := chi.URLParam(r, "branch") 744 - branch, _ = url.PathUnescape(branch) 745 - 746 - relativeRepoPath := filepath.Join(did, name) 747 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 748 - 749 - gr, err := git.PlainOpen(repoPath) 750 - if err != nil { 751 - log.Println(err) 752 - notFound(w) 753 - return 754 - } 755 - 756 - forkCommit, err := gr.ResolveRevision(branch) 757 - if err != nil { 758 - l.Error("error resolving ref revision", "msg", err.Error()) 759 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 760 - return 761 - } 762 - 763 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 764 - if err != nil { 765 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 766 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 767 - return 768 - } 769 - 770 - status := types.UpToDate 771 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 772 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 773 - if err != nil { 774 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 775 - return 776 - } 777 - 778 - if isAncestor { 779 - status = types.FastForwardable 780 - } else { 781 - status = types.Conflict 782 - } 783 - } 784 - 785 - w.Header().Set("Content-Type", "application/json") 786 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 787 - } 788 - 789 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 790 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 791 - ref := chi.URLParam(r, "ref") 792 - ref, _ = url.PathUnescape(ref) 793 - 794 - l := h.l.With("handler", "RepoLanguages") 795 - 796 - gr, err := git.Open(repoPath, ref) 797 - if err != nil { 798 - l.Error("opening repo", "error", err.Error()) 799 - notFound(w) 800 - return 801 - } 802 - 803 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 804 - defer cancel() 805 - 806 - sizes, err := gr.AnalyzeLanguages(ctx) 807 - if err != nil { 808 - l.Error("failed to analyze languages", "error", err.Error()) 809 - writeError(w, err.Error(), http.StatusNoContent) 810 - return 811 - } 812 - 813 - resp := types.RepoLanguageResponse{Languages: sizes} 814 - 815 - writeJSON(w, resp) 816 - } 817 - 818 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 819 - l := h.l.With("handler", "RepoForkSync") 820 - 821 - data := struct { 822 - Did string `json:"did"` 823 - Source string `json:"source"` 824 - Name string `json:"name,omitempty"` 825 - }{} 826 - 827 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 828 - writeError(w, "invalid request body", http.StatusBadRequest) 829 - return 830 - } 831 - 832 - did := data.Did 833 - source := data.Source 834 - 835 - if did == "" || source == "" { 836 - l.Error("invalid request body, empty did or name") 837 - w.WriteHeader(http.StatusBadRequest) 838 - return 839 - } 840 - 841 - var name string 842 - if data.Name != "" { 843 - name = data.Name 844 - } else { 845 - name = filepath.Base(source) 846 - } 847 - 848 - branch := chi.URLParam(r, "branch") 849 - branch, _ = url.PathUnescape(branch) 850 - 851 - relativeRepoPath := filepath.Join(did, name) 852 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 853 - 854 - gr, err := git.PlainOpen(repoPath) 855 - if err != nil { 856 - log.Println(err) 857 - notFound(w) 858 - return 859 - } 860 - 861 - err = gr.Sync(branch) 862 - if err != nil { 863 - l.Error("error syncing repo fork", "error", err.Error()) 864 - writeError(w, err.Error(), http.StatusInternalServerError) 865 - return 866 - } 867 - 868 - w.WriteHeader(http.StatusNoContent) 869 - } 870 - 871 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 872 - l := h.l.With("handler", "RepoFork") 873 - 874 - data := struct { 875 - Did string `json:"did"` 876 - Source string `json:"source"` 877 - Name string `json:"name,omitempty"` 878 - }{} 879 - 880 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 881 - writeError(w, "invalid request body", http.StatusBadRequest) 882 - return 883 - } 884 - 885 - did := data.Did 886 - source := data.Source 887 - 888 - if did == "" || source == "" { 889 - l.Error("invalid request body, empty did or name") 890 - w.WriteHeader(http.StatusBadRequest) 891 - return 892 - } 893 - 894 - var name string 895 - if data.Name != "" { 896 - name = data.Name 897 - } else { 898 - name = filepath.Base(source) 899 - } 900 - 901 - relativeRepoPath := filepath.Join(did, name) 902 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 903 - 904 - err := git.Fork(repoPath, source) 905 - if err != nil { 906 - l.Error("forking repo", "error", err.Error()) 907 - writeError(w, err.Error(), http.StatusInternalServerError) 908 - return 909 - } 910 - 911 - // add perms for this user to access the repo 912 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 913 - if err != nil { 914 - l.Error("adding repo permissions", "error", err.Error()) 915 - writeError(w, err.Error(), http.StatusInternalServerError) 916 - return 917 - } 918 - 919 - hook.SetupRepo( 920 - hook.Config( 921 - hook.WithScanPath(h.c.Repo.ScanPath), 922 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 923 - ), 924 - repoPath, 925 - ) 926 - 927 - w.WriteHeader(http.StatusNoContent) 928 - } 929 - 930 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 931 - l := h.l.With("handler", "RemoveRepo") 932 - 933 - data := struct { 934 - Did string `json:"did"` 935 - Name string `json:"name"` 936 - }{} 937 - 938 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 939 - writeError(w, "invalid request body", http.StatusBadRequest) 940 - return 941 - } 942 - 943 - did := data.Did 944 - name := data.Name 945 - 946 - if did == "" || name == "" { 947 - l.Error("invalid request body, empty did or name") 948 - w.WriteHeader(http.StatusBadRequest) 949 - return 950 - } 951 - 952 - relativeRepoPath := filepath.Join(did, name) 953 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 954 - err := os.RemoveAll(repoPath) 955 - if err != nil { 956 - l.Error("removing repo", "error", err.Error()) 957 - writeError(w, err.Error(), http.StatusInternalServerError) 958 - return 959 - } 960 - 961 - w.WriteHeader(http.StatusNoContent) 962 - 963 - } 964 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 965 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 966 - 967 - data := types.MergeRequest{} 968 - 969 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 970 - writeError(w, err.Error(), http.StatusBadRequest) 971 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 972 - return 973 - } 974 - 975 - mo := &git.MergeOptions{ 976 - AuthorName: data.AuthorName, 977 - AuthorEmail: data.AuthorEmail, 978 - CommitBody: data.CommitBody, 979 - CommitMessage: data.CommitMessage, 980 - } 981 - 982 - patch := data.Patch 983 - branch := data.Branch 984 - gr, err := git.Open(path, branch) 985 - if err != nil { 986 - notFound(w) 987 - return 988 - } 989 - 990 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 991 - 992 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 993 - var mergeErr *git.ErrMerge 994 - if errors.As(err, &mergeErr) { 995 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 996 - for i, conflict := range mergeErr.Conflicts { 997 - conflicts[i] = types.ConflictInfo{ 998 - Filename: conflict.Filename, 999 - Reason: conflict.Reason, 1000 - } 1001 - } 1002 - response := types.MergeCheckResponse{ 1003 - IsConflicted: true, 1004 - Conflicts: conflicts, 1005 - Message: mergeErr.Message, 1006 - } 1007 - writeConflict(w, response) 1008 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1009 - } else { 1010 - writeError(w, err.Error(), http.StatusBadRequest) 1011 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 1012 - } 1013 - return 1014 - } 1015 - 1016 - w.WriteHeader(http.StatusOK) 1017 - } 1018 - 1019 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1020 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1021 - 1022 - var data struct { 1023 - Patch string `json:"patch"` 1024 - Branch string `json:"branch"` 1025 - } 1026 - 1027 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1028 - writeError(w, err.Error(), http.StatusBadRequest) 1029 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1030 - return 1031 - } 1032 - 1033 - patch := data.Patch 1034 - branch := data.Branch 1035 - gr, err := git.Open(path, branch) 1036 - if err != nil { 1037 - notFound(w) 1038 - return 1039 - } 1040 - 1041 - err = gr.MergeCheck([]byte(patch), branch) 1042 - if err == nil { 1043 - response := types.MergeCheckResponse{ 1044 - IsConflicted: false, 1045 - } 1046 - writeJSON(w, response) 1047 - return 1048 - } 1049 - 1050 - var mergeErr *git.ErrMerge 1051 - if errors.As(err, &mergeErr) { 1052 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1053 - for i, conflict := range mergeErr.Conflicts { 1054 - conflicts[i] = types.ConflictInfo{ 1055 - Filename: conflict.Filename, 1056 - Reason: conflict.Reason, 1057 - } 1058 - } 1059 - response := types.MergeCheckResponse{ 1060 - IsConflicted: true, 1061 - Conflicts: conflicts, 1062 - Message: mergeErr.Message, 1063 - } 1064 - writeConflict(w, response) 1065 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1066 - return 1067 - } 1068 - writeError(w, err.Error(), http.StatusInternalServerError) 1069 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1070 - } 1071 - 1072 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1073 - rev1 := chi.URLParam(r, "rev1") 1074 - rev1, _ = url.PathUnescape(rev1) 1075 - 1076 - rev2 := chi.URLParam(r, "rev2") 1077 - rev2, _ = url.PathUnescape(rev2) 1078 - 1079 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1080 - 1081 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1082 - gr, err := git.PlainOpen(path) 1083 - if err != nil { 1084 - notFound(w) 1085 - return 1086 - } 1087 - 1088 - commit1, err := gr.ResolveRevision(rev1) 1089 - if err != nil { 1090 - l.Error("error resolving revision 1", "msg", err.Error()) 1091 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1092 - return 1093 - } 1094 - 1095 - commit2, err := gr.ResolveRevision(rev2) 1096 - if err != nil { 1097 - l.Error("error resolving revision 2", "msg", err.Error()) 1098 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1099 - return 1100 - } 1101 - 1102 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1103 - if err != nil { 1104 - l.Error("error comparing revisions", "msg", err.Error()) 1105 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1106 - return 1107 - } 1108 - 1109 - writeJSON(w, types.RepoFormatPatchResponse{ 1110 - Rev1: commit1.Hash.String(), 1111 - Rev2: commit2.Hash.String(), 1112 - FormatPatch: formatPatch, 1113 - Patch: rawPatch, 1114 - }) 1115 - return 1116 - } 1117 - 1118 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1119 - l := h.l.With("handler", "NewHiddenRef") 1120 - 1121 - forkRef := chi.URLParam(r, "forkRef") 1122 - forkRef, _ = url.PathUnescape(forkRef) 1123 - 1124 - remoteRef := chi.URLParam(r, "remoteRef") 1125 - remoteRef, _ = url.PathUnescape(remoteRef) 1126 - 1127 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1128 - gr, err := git.PlainOpen(path) 1129 - if err != nil { 1130 - notFound(w) 1131 - return 1132 - } 1133 - 1134 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1135 - if err != nil { 1136 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1137 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1138 - return 1139 - } 1140 - 1141 - w.WriteHeader(http.StatusNoContent) 1142 - return 1143 - } 1144 - 1145 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1146 - l := h.l.With("handler", "AddMember") 1147 - 1148 - data := struct { 1149 - Did string `json:"did"` 1150 - }{} 1151 - 1152 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1153 - writeError(w, "invalid request body", http.StatusBadRequest) 1154 - return 1155 - } 1156 - 1157 - did := data.Did 1158 - 1159 - if err := h.db.AddDid(did); err != nil { 1160 - l.Error("adding did", "error", err.Error()) 1161 - writeError(w, err.Error(), http.StatusInternalServerError) 1162 - return 1163 - } 1164 - h.jc.AddDid(did) 1165 - 1166 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1167 - l.Error("adding member", "error", err.Error()) 1168 - writeError(w, err.Error(), http.StatusInternalServerError) 1169 - return 1170 - } 1171 - 1172 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1173 - l.Error("fetching and adding keys", "error", err.Error()) 1174 - writeError(w, err.Error(), http.StatusInternalServerError) 1175 - return 1176 - } 1177 - 1178 - w.WriteHeader(http.StatusNoContent) 1179 - } 1180 - 1181 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1182 - l := h.l.With("handler", "AddRepoCollaborator") 1183 - 1184 - data := struct { 1185 - Did string `json:"did"` 1186 - }{} 1187 - 1188 - ownerDid := chi.URLParam(r, "did") 1189 - repo := chi.URLParam(r, "name") 1190 - 1191 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1192 - writeError(w, "invalid request body", http.StatusBadRequest) 1193 - return 1194 - } 1195 - 1196 - if err := h.db.AddDid(data.Did); err != nil { 1197 - l.Error("adding did", "error", err.Error()) 1198 - writeError(w, err.Error(), http.StatusInternalServerError) 1199 - return 1200 - } 1201 - h.jc.AddDid(data.Did) 1202 - 1203 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1204 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1205 - l.Error("adding repo collaborator", "error", err.Error()) 1206 - writeError(w, err.Error(), http.StatusInternalServerError) 1207 - return 1208 - } 1209 - 1210 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1211 - l.Error("fetching and adding keys", "error", err.Error()) 1212 - writeError(w, err.Error(), http.StatusInternalServerError) 1213 - return 1214 - } 1215 - 1216 - w.WriteHeader(http.StatusNoContent) 1217 - } 1218 - 1219 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1220 - l := h.l.With("handler", "DefaultBranch") 1221 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1222 - 1223 - gr, err := git.Open(path, "") 1224 - if err != nil { 1225 - notFound(w) 1226 - return 1227 - } 1228 - 1229 - branch, err := gr.FindMainBranch() 1230 - if err != nil { 1231 - writeError(w, err.Error(), http.StatusInternalServerError) 1232 - l.Error("getting default branch", "error", err.Error()) 1233 - return 1234 - } 1235 - 1236 - writeJSON(w, types.RepoDefaultBranchResponse{ 1237 - Branch: branch, 1238 - }) 1239 - } 1240 - 1241 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1242 - l := h.l.With("handler", "SetDefaultBranch") 1243 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1244 - 1245 - data := struct { 1246 - Branch string `json:"branch"` 1247 - }{} 1248 - 1249 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1250 - writeError(w, err.Error(), http.StatusBadRequest) 1251 - return 1252 - } 1253 - 1254 - gr, err := git.PlainOpen(path) 1255 - if err != nil { 1256 - notFound(w) 1257 - return 1258 - } 1259 - 1260 - err = gr.SetDefaultBranch(data.Branch) 1261 - if err != nil { 1262 - writeError(w, err.Error(), http.StatusInternalServerError) 1263 - l.Error("setting default branch", "error", err.Error()) 1264 - return 1265 - } 1266 - 1267 - w.WriteHeader(http.StatusNoContent) 1268 - } 1269 - 1270 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1271 - l := h.l.With("handler", "Init") 1272 - 1273 - if h.knotInitialized { 1274 - writeError(w, "knot already initialized", http.StatusConflict) 1275 - return 1276 - } 1277 - 1278 - data := struct { 1279 - Did string `json:"did"` 1280 - }{} 1281 - 1282 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1283 - l.Error("failed to decode request body", "error", err.Error()) 1284 - writeError(w, "invalid request body", http.StatusBadRequest) 1285 - return 1286 - } 1287 - 1288 - if data.Did == "" { 1289 - l.Error("empty DID in request", "did", data.Did) 1290 - writeError(w, "did is empty", http.StatusBadRequest) 1291 - return 1292 - } 1293 - 1294 - if err := h.db.AddDid(data.Did); err != nil { 1295 - l.Error("failed to add DID", "error", err.Error()) 1296 - writeError(w, err.Error(), http.StatusInternalServerError) 1297 - return 1298 - } 1299 - h.jc.AddDid(data.Did) 1300 - 1301 - if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1302 - l.Error("adding owner", "error", err.Error()) 1303 - writeError(w, err.Error(), http.StatusInternalServerError) 1304 - return 1305 - } 1306 - 1307 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1308 - l.Error("fetching and adding keys", "error", err.Error()) 1309 - writeError(w, err.Error(), http.StatusInternalServerError) 1310 - return 1311 - } 1312 - 1313 - close(h.init) 1314 - 1315 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1316 - mac.Write([]byte("ok")) 1317 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1318 - 1319 - w.WriteHeader(http.StatusNoContent) 1320 - } 1321 - 1322 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1323 - w.Write([]byte("ok")) 1324 - } 1325 - 1326 - func validateRepoName(name string) error { 1327 - // check for path traversal attempts 1328 - if name == "." || name == ".." || 1329 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1330 - return fmt.Errorf("Repository name contains invalid path characters") 1331 - } 1332 - 1333 - // check for sequences that could be used for traversal when normalized 1334 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1335 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1336 - return fmt.Errorf("Repository name contains invalid path sequence") 1337 - } 1338 - 1339 - // then continue with character validation 1340 - for _, char := range name { 1341 - if !((char >= 'a' && char <= 'z') || 1342 - (char >= 'A' && char <= 'Z') || 1343 - (char >= '0' && char <= '9') || 1344 - char == '-' || char == '_' || char == '.') { 1345 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1346 - } 1347 - } 1348 - 1349 - // additional check to prevent multiple sequential dots 1350 - if strings.Contains(name, "..") { 1351 - return fmt.Errorf("Repository name cannot contain sequential dots") 1352 - } 1353 - 1354 - // if all checks pass 1355 - return nil 1356 - }
+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 + }
+49
knotserver/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 12 + cursor := r.URL.Query().Get("cursor") 13 + 14 + limit := 100 // default 15 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 16 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 17 + limit = l 18 + } 19 + } 20 + 21 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 22 + if err != nil { 23 + x.Logger.Error("failed to get public keys", "error", err) 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InternalServerError"), 26 + xrpcerr.WithMessage("failed to retrieve public keys"), 27 + ), http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 32 + for _, key := range keys { 33 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 34 + Did: key.Did, 35 + Key: key.Key, 36 + CreatedAt: key.CreatedAt, 37 + }) 38 + } 39 + 40 + response := tangled.KnotListKeys_Output{ 41 + Keys: publicKeys, 42 + } 43 + 44 + if nextCursor != "" { 45 + response.Cursor = &nextCursor 46 + } 47 + 48 + writeJson(w, response) 49 + }
+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 + }
+22
knotserver/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/api/tangled" 7 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 + ) 9 + 10 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 11 + owner := x.Config.Server.Owner 12 + if owner == "" { 13 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 14 + return 15 + } 16 + 17 + response := tangled.Owner_Output{ 18 + Owner: owner, 19 + } 20 + 21 + writeJson(w, response) 22 + }
+81
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 := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + format := r.URL.Query().Get("format") 27 + if format == "" { 28 + format = "tar.gz" // default 29 + } 30 + 31 + prefix := r.URL.Query().Get("prefix") 32 + 33 + if format != "tar.gz" { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("InvalidRequest"), 36 + xrpcerr.WithMessage("only tar.gz format is supported"), 37 + ), http.StatusBadRequest) 38 + return 39 + } 40 + 41 + gr, err := git.Open(repoPath, ref) 42 + if err != nil { 43 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 + return 45 + } 46 + 47 + repoParts := strings.Split(repo, "/") 48 + repoName := repoParts[len(repoParts)-1] 49 + 50 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 51 + 52 + var archivePrefix string 53 + if prefix != "" { 54 + archivePrefix = prefix 55 + } else { 56 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 57 + } 58 + 59 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 60 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 61 + w.Header().Set("Content-Type", "application/gzip") 62 + 63 + gw := gzip.NewWriter(w) 64 + defer gw.Close() 65 + 66 + err = gr.WriteTar(gw, archivePrefix) 67 + if err != nil { 68 + // once we start writing to the body we can't report error anymore 69 + // so we are only left with logging the error 70 + x.Logger.Error("writing tar file", "error", err.Error()) 71 + return 72 + } 73 + 74 + err = gw.Flush() 75 + if err != nil { 76 + // once we start writing to the body we can't report error anymore 77 + // so we are only left with logging the error 78 + x.Logger.Error("flushing", "error", err.Error()) 79 + return 80 + } 81 + }
+143
knotserver/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 18 + repo := r.URL.Query().Get("repo") 19 + repoPath, err := x.parseRepoParam(repo) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + ref := r.URL.Query().Get("ref") 26 + // ref can be empty (git.Open handles this) 27 + 28 + treePath := r.URL.Query().Get("path") 29 + if treePath == "" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("missing path parameter"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + raw := r.URL.Query().Get("raw") == "true" 38 + 39 + gr, err := git.Open(repoPath, ref) 40 + if err != nil { 41 + writeError(w, xrpcerr.RefNotFoundError, 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 + w.Header().Set("Content-Type", mimeType) 73 + 74 + case strings.HasPrefix(mimeType, "text/"): 75 + w.Header().Set("Cache-Control", "public, no-cache") 76 + // serve all text content as text/plain 77 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 78 + 79 + case isTextualMimeType(mimeType): 80 + // handle textual application types (json, xml, etc.) as text/plain 81 + w.Header().Set("Cache-Control", "public, no-cache") 82 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 83 + 84 + default: 85 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("InvalidRequest"), 88 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 89 + ), http.StatusForbidden) 90 + return 91 + } 92 + w.Write(contents) 93 + return 94 + } 95 + 96 + isTextual := func(mt string) bool { 97 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 98 + } 99 + 100 + var content string 101 + var encoding string 102 + 103 + isBinary := !isTextual(mimeType) 104 + 105 + if isBinary { 106 + content = base64.StdEncoding.EncodeToString(contents) 107 + encoding = "base64" 108 + } else { 109 + content = string(contents) 110 + encoding = "utf-8" 111 + } 112 + 113 + response := tangled.RepoBlob_Output{ 114 + Ref: ref, 115 + Path: treePath, 116 + Content: content, 117 + Encoding: &encoding, 118 + Size: &[]int64{int64(len(contents))}[0], 119 + IsBinary: &isBinary, 120 + } 121 + 122 + if mimeType != "" { 123 + response.MimeType = &mimeType 124 + } 125 + 126 + writeJson(w, response) 127 + } 128 + 129 + // isTextualMimeType returns true if the MIME type represents textual content 130 + // that should be served as text/plain for security reasons 131 + func isTextualMimeType(mimeType string) bool { 132 + textualTypes := []string{ 133 + "application/json", 134 + "application/xml", 135 + "application/yaml", 136 + "application/x-yaml", 137 + "application/toml", 138 + "application/javascript", 139 + "application/ecmascript", 140 + } 141 + 142 + return slices.Contains(textualTypes, mimeType) 143 + }
+85
knotserver/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "time" 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.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + ref, err := gr.Branch(branchName) 39 + if err != nil { 40 + x.Logger.Error("getting branch", "error", err.Error()) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("BranchNotFound"), 43 + xrpcerr.WithMessage("branch not found"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + commit, err := gr.Commit(ref.Hash()) 49 + if err != nil { 50 + x.Logger.Error("getting commit object", "error", err.Error()) 51 + writeError(w, xrpcerr.NewXrpcError( 52 + xrpcerr.WithTag("BranchNotFound"), 53 + xrpcerr.WithMessage("failed to get commit object"), 54 + ), http.StatusInternalServerError) 55 + return 56 + } 57 + 58 + defaultBranch, err := gr.FindMainBranch() 59 + isDefault := false 60 + if err != nil { 61 + x.Logger.Error("getting default branch", "error", err.Error()) 62 + } else if defaultBranch == branchName { 63 + isDefault = true 64 + } 65 + 66 + response := tangled.RepoBranch_Output{ 67 + Name: ref.Name().Short(), 68 + Hash: ref.Hash().String(), 69 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 70 + When: commit.Author.When.Format(time.RFC3339), 71 + IsDefault: &isDefault, 72 + } 73 + 74 + if commit.Message != "" { 75 + response.Message = &commit.Message 76 + } 77 + 78 + response.Author = &tangled.RepoBranch_Signature{ 79 + Name: commit.Author.Name, 80 + Email: commit.Author.Email, 81 + When: commit.Author.When.Format(time.RFC3339), 82 + } 83 + 84 + writeJson(w, response) 85 + }
+56
knotserver/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoBranches(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 + cursor := r.URL.Query().Get("cursor") 21 + 22 + // limit := 50 // default 23 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 + // limit = l 26 + // } 27 + // } 28 + 29 + limit := 500 30 + 31 + gr, err := git.PlainOpen(repoPath) 32 + if err != nil { 33 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 34 + return 35 + } 36 + 37 + branches, _ := gr.Branches() 38 + 39 + offset := 0 40 + if cursor != "" { 41 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 + offset = o 43 + } 44 + } 45 + 46 + end := min(offset+limit, len(branches)) 47 + 48 + paginatedBranches := branches[offset:end] 49 + 50 + // Create response using existing types.RepoBranchesResponse 51 + response := types.RepoBranchesResponse{ 52 + Branches: paginatedBranches, 53 + } 54 + 55 + writeJson(w, response) 56 + }
+82
knotserver/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoCompare(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 + rev1 := r.URL.Query().Get("rev1") 21 + if rev1 == "" { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("InvalidRequest"), 24 + xrpcerr.WithMessage("missing rev1 parameter"), 25 + ), http.StatusBadRequest) 26 + return 27 + } 28 + 29 + rev2 := r.URL.Query().Get("rev2") 30 + if rev2 == "" { 31 + writeError(w, xrpcerr.NewXrpcError( 32 + xrpcerr.WithTag("InvalidRequest"), 33 + xrpcerr.WithMessage("missing rev2 parameter"), 34 + ), http.StatusBadRequest) 35 + return 36 + } 37 + 38 + gr, err := git.PlainOpen(repoPath) 39 + if err != nil { 40 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 41 + return 42 + } 43 + 44 + commit1, err := gr.ResolveRevision(rev1) 45 + if err != nil { 46 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("RevisionNotFound"), 49 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 50 + ), http.StatusBadRequest) 51 + return 52 + } 53 + 54 + commit2, err := gr.ResolveRevision(rev2) 55 + if err != nil { 56 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 57 + writeError(w, xrpcerr.NewXrpcError( 58 + xrpcerr.WithTag("RevisionNotFound"), 59 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 60 + ), http.StatusBadRequest) 61 + return 62 + } 63 + 64 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 65 + if err != nil { 66 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 67 + writeError(w, xrpcerr.NewXrpcError( 68 + xrpcerr.WithTag("CompareError"), 69 + xrpcerr.WithMessage("error comparing revisions"), 70 + ), http.StatusBadRequest) 71 + return 72 + } 73 + 74 + response := types.RepoFormatPatchResponse{ 75 + Rev1: commit1.Hash.String(), 76 + Rev2: commit2.Hash.String(), 77 + FormatPatch: formatPatch, 78 + Patch: rawPatch, 79 + } 80 + 81 + writeJson(w, response) 82 + }
+41
knotserver/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/knotserver/git" 7 + "tangled.sh/tangled.sh/core/types" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 12 + repo := r.URL.Query().Get("repo") 13 + repoPath, err := x.parseRepoParam(repo) 14 + if err != nil { 15 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 16 + return 17 + } 18 + 19 + ref := r.URL.Query().Get("ref") 20 + // ref can be empty (git.Open handles this) 21 + 22 + gr, err := git.Open(repoPath, ref) 23 + if err != nil { 24 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 25 + return 26 + } 27 + 28 + diff, err := gr.Diff() 29 + if err != nil { 30 + x.Logger.Error("getting diff", "error", err.Error()) 31 + writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + response := types.RepoCommitResponse{ 36 + Ref: ref, 37 + Diff: diff, 38 + } 39 + 40 + writeJson(w, response) 41 + }
+39
knotserver/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "time" 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.PlainOpen(repoPath) 21 + 22 + branch, err := gr.FindMainBranch() 23 + if err != nil { 24 + x.Logger.Error("getting default branch", "error", err.Error()) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("failed to get default branch"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + response := tangled.RepoGetDefaultBranch_Output{ 33 + Name: branch, 34 + Hash: "", 35 + When: time.UnixMicro(0).Format(time.RFC3339), 36 + } 37 + 38 + writeJson(w, response) 39 + }
+76
knotserver/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "math" 6 + "net/http" 7 + "time" 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) RepoLanguages(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 + ref := r.URL.Query().Get("ref") 23 + 24 + gr, err := git.Open(repoPath, ref) 25 + if err != nil { 26 + x.Logger.Error("opening repo", "error", err.Error()) 27 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 28 + return 29 + } 30 + 31 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 32 + defer cancel() 33 + 34 + sizes, err := gr.AnalyzeLanguages(ctx) 35 + if err != nil { 36 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 37 + writeError(w, xrpcerr.NewXrpcError( 38 + xrpcerr.WithTag("InvalidRequest"), 39 + xrpcerr.WithMessage("failed to analyze repository languages"), 40 + ), http.StatusNoContent) 41 + return 42 + } 43 + 44 + var apiLanguages []*tangled.RepoLanguages_Language 45 + var totalSize int64 46 + 47 + for _, size := range sizes { 48 + totalSize += size 49 + } 50 + 51 + for name, size := range sizes { 52 + percentagef64 := float64(size) / float64(totalSize) * 100 53 + percentage := math.Round(percentagef64) 54 + 55 + lang := &tangled.RepoLanguages_Language{ 56 + Name: name, 57 + Size: size, 58 + Percentage: int64(percentage), 59 + } 60 + 61 + apiLanguages = append(apiLanguages, lang) 62 + } 63 + 64 + response := tangled.RepoLanguages_Output{ 65 + Ref: ref, 66 + Languages: apiLanguages, 67 + } 68 + 69 + if totalSize > 0 { 70 + response.TotalSize = &totalSize 71 + totalFiles := int64(len(sizes)) 72 + response.TotalFiles = &totalFiles 73 + } 74 + 75 + writeJson(w, response) 76 + }
+81
knotserver/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoLog(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 + ref := r.URL.Query().Get("ref") 21 + 22 + path := r.URL.Query().Get("path") 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 + return 36 + } 37 + 38 + offset := 0 39 + if cursor != "" { 40 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 41 + offset = o 42 + } 43 + } 44 + 45 + commits, err := gr.Commits(offset, limit) 46 + if err != nil { 47 + x.Logger.Error("fetching commits", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("PathNotFound"), 50 + xrpcerr.WithMessage("failed to read commit log"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + total, err := gr.TotalCommits() 56 + if err != nil { 57 + x.Logger.Error("fetching total commits", "error", err.Error()) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("InternalServerError"), 60 + xrpcerr.WithMessage("failed to fetch total commits"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // Create response using existing types.RepoLogResponse 66 + response := types.RepoLogResponse{ 67 + Commits: commits, 68 + Ref: ref, 69 + Page: (offset / limit) + 1, 70 + PerPage: limit, 71 + Total: total, 72 + } 73 + 74 + if path != "" { 75 + response.Description = path 76 + } 77 + 78 + response.Log = true 79 + 80 + writeJson(w, response) 81 + }
+86
knotserver/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + "tangled.sh/tangled.sh/core/types" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + tags, err := gr.Tags() 40 + if err != nil { 41 + x.Logger.Warn("getting tags", "error", err.Error()) 42 + tags = []object.Tag{} 43 + } 44 + 45 + rtags := []*types.TagReference{} 46 + for _, tag := range tags { 47 + var target *object.Tag 48 + if tag.Target != plumbing.ZeroHash { 49 + target = &tag 50 + } 51 + tr := types.TagReference{ 52 + Tag: target, 53 + } 54 + 55 + tr.Reference = types.Reference{ 56 + Name: tag.Name, 57 + Hash: tag.Hash.String(), 58 + } 59 + 60 + if tag.Message != "" { 61 + tr.Message = tag.Message 62 + } 63 + 64 + rtags = append(rtags, &tr) 65 + } 66 + 67 + // apply pagination manually 68 + offset := 0 69 + if cursor != "" { 70 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 + offset = o 72 + } 73 + } 74 + 75 + // calculate end index 76 + end := min(offset+limit, len(rtags)) 77 + 78 + paginatedTags := rtags[offset:end] 79 + 80 + // Create response using existing types.RepoTagsResponse 81 + response := types.RepoTagsResponse{ 82 + Tags: paginatedTags, 83 + } 84 + 85 + writeJson(w, response) 86 + }
+89
knotserver/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "path/filepath" 6 + "time" 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) RepoTree(w http.ResponseWriter, r *http.Request) { 14 + ctx := r.Context() 15 + 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + path := r.URL.Query().Get("path") 27 + // path can be empty (defaults to root) 28 + 29 + gr, err := git.Open(repoPath, ref) 30 + if err != nil { 31 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 32 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 33 + return 34 + } 35 + 36 + files, err := gr.FileTree(ctx, path) 37 + if err != nil { 38 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("PathNotFound"), 41 + xrpcerr.WithMessage("failed to read repository tree"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + // convert NiceTree -> tangled.RepoTree_TreeEntry 47 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 + for i, file := range files { 49 + entry := &tangled.RepoTree_TreeEntry{ 50 + Name: file.Name, 51 + Mode: file.Mode, 52 + Size: file.Size, 53 + Is_file: file.IsFile, 54 + Is_subtree: file.IsSubtree, 55 + } 56 + 57 + if file.LastCommit != nil { 58 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 59 + Hash: file.LastCommit.Hash.String(), 60 + Message: file.LastCommit.Message, 61 + When: file.LastCommit.When.Format(time.RFC3339), 62 + } 63 + } 64 + 65 + treeEntries[i] = entry 66 + } 67 + 68 + var parentPtr *string 69 + if path != "" { 70 + parentPtr = &path 71 + } 72 + 73 + var dotdotPtr *string 74 + if path != "" { 75 + dotdot := filepath.Dir(path) 76 + if dotdot != "." { 77 + dotdotPtr = &dotdot 78 + } 79 + } 80 + 81 + response := tangled.RepoTree_Output{ 82 + Ref: ref, 83 + Parent: parentPtr, 84 + Dotdot: dotdotPtr, 85 + Files: treeEntries, 86 + } 87 + 88 + writeJson(w, response) 89 + }
-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
+60
knotserver/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "runtime/debug" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + ) 10 + 11 + // version is set during build time. 12 + var version string 13 + 14 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 + if version == "" { 16 + info, ok := debug.ReadBuildInfo() 17 + if !ok { 18 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 19 + return 20 + } 21 + 22 + var modVer string 23 + var sha string 24 + var modified bool 25 + 26 + for _, mod := range info.Deps { 27 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 28 + modVer = mod.Version 29 + break 30 + } 31 + } 32 + 33 + for _, setting := range info.Settings { 34 + switch setting.Key { 35 + case "vcs.revision": 36 + sha = setting.Value 37 + case "vcs.modified": 38 + modified = setting.Value == "true" 39 + } 40 + } 41 + 42 + if modVer == "" { 43 + modVer = "unknown" 44 + } 45 + 46 + if sha == "" { 47 + version = modVer 48 + } else if modified { 49 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 50 + } else { 51 + version = fmt.Sprintf("%s (%s)", modVer, sha) 52 + } 53 + } 54 + 55 + response := tangled.KnotVersion_Output{ 56 + Version: version, 57 + } 58 + 59 + writeJson(w, response) 60 + }
+127
knotserver/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + "strings" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 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/notifier" 16 + "tangled.sh/tangled.sh/core/rbac" 17 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + 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 + ServiceAuth *serviceauth.ServiceAuth 32 + } 33 + 34 + func (x *Xrpc) Router() http.Handler { 35 + r := chi.NewRouter() 36 + 37 + r.Group(func(r chi.Router) { 38 + r.Use(x.ServiceAuth.VerifyServiceAuth) 39 + 40 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 44 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 45 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 46 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 47 + }) 48 + 49 + // merge check is an open endpoint 50 + // 51 + // TODO: should we constrain this more? 52 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 53 + // - use ETags on clients to keep requests to a minimum 54 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 55 + 56 + // repo query endpoints (no auth required) 57 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 58 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 59 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 60 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 61 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 62 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 63 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 64 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 65 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 66 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 67 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 68 + 69 + // knot query endpoints (no auth required) 70 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 71 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 72 + 73 + // service query endpoints (no auth required) 74 + r.Get("/"+tangled.OwnerNSID, x.Owner) 75 + 76 + return r 77 + } 78 + 79 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 80 + // the full repository path on disk 81 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 82 + if repo == "" { 83 + return "", xrpcerr.NewXrpcError( 84 + xrpcerr.WithTag("InvalidRequest"), 85 + xrpcerr.WithMessage("missing repo parameter"), 86 + ) 87 + } 88 + 89 + // Parse repo string (did/repoName format) 90 + parts := strings.SplitN(repo, "/", 2) 91 + if len(parts) != 2 { 92 + return "", xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InvalidRequest"), 94 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 95 + ) 96 + } 97 + 98 + did := parts[0] 99 + repoName := parts[1] 100 + 101 + // Construct repository path using the same logic as didPath 102 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 103 + if err != nil { 104 + return "", xrpcerr.RepoNotFoundError 105 + } 106 + 107 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 108 + if err != nil { 109 + return "", xrpcerr.RepoNotFoundError 110 + } 111 + 112 + return repoPath, nil 113 + } 114 + 115 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 116 + w.Header().Set("Content-Type", "application/json") 117 + w.WriteHeader(status) 118 + json.NewEncoder(w).Encode(e) 119 + } 120 + 121 + func writeJson(w http.ResponseWriter, response any) { 122 + w.Header().Set("Content-Type", "application/json") 123 + if err := json.NewEncoder(w).Encode(response); err != nil { 124 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 125 + return 126 + } 127 + }
+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)})
+11 -2
nix/gomod2nix.toml
··· 425 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 426 version = "v0.3.1" 427 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/wyatt915/goldmark-treeblood"] 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 + [mod."github.com/wyatt915/treeblood"] 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 428 434 [mod."github.com/yuin/goldmark"] 429 - version = "v1.4.13" 430 - hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI=" 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 + [mod."github.com/yuin/goldmark-highlighting/v2"] 438 + version = "v2.0.0-20230729083705-37449abec8cc" 439 + hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 431 440 [mod."gitlab.com/yawning/secp256k1-voi"] 432 441 version = "v0.0.0-20230925100816-f2616030848b" 433 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
+5 -5
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 { ··· 199 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 202 203 ]; 203 - EnvironmentFile = cfg.server.secretFile; 204 204 ExecStart = "${cfg.package}/bin/knot server"; 205 205 Restart = "always"; 206 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";
+1 -1
nix/pkgs/appview-static-files.nix
··· 22 22 cp -rf ${lucide-src}/*.svg icons/ 23 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 - 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/ 26 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 27 # for whatever reason (produces broken css), so we are doing this instead 28 28 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+17 -12
nix/pkgs/knot-unwrapped.nix
··· 3 3 modules, 4 4 sqlite-lib, 5 5 src, 6 - }: 7 - buildGoApplication { 8 - pname = "knot"; 9 - version = "0.1.0"; 10 - inherit src modules; 6 + }: let 7 + version = "1.9.0-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 + 13 + doCheck = false; 11 14 12 - doCheck = false; 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 13 17 14 - subPackages = ["cmd/knot"]; 15 - tags = ["libsqlite3"]; 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 16 21 17 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 19 - CGO_ENABLED = 1; 20 - } 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }
+4 -1
nix/vm.nix
··· 70 70 }; 71 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 72 networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 73 74 services.getty.autologinUser = "root"; 74 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 75 76 services.tangled-knot = { 76 77 enable = true; 77 78 motd = "Welcome to the development knot!\n"; 78 79 server = { 79 - secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 80 81 hostname = "localhost:6000"; 81 82 listenAddr = "0.0.0.0:6000"; 82 83 }; ··· 88 89 hostname = "localhost:6555"; 89 90 listenAddr = "0.0.0.0:6555"; 90 91 dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 91 94 secrets = { 92 95 provider = "sqlite"; 93 96 };
+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 }
+50 -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() ··· 261 264 Rkey: msg.Rkey, 262 265 } 263 266 267 + workflows := make(map[models.Engine][]models.Workflow) 268 + 264 269 for _, w := range tpl.Workflows { 265 270 if w != nil { 266 - 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{ 267 297 PipelineId: pipelineId, 268 298 Name: w.Name, 269 299 }, s.n) ··· 273 303 } 274 304 } 275 305 276 - spl := models.ToPipeline(tpl, *s.cfg) 277 - 278 306 ok := s.jq.Enqueue(queue.Job{ 279 307 Run: func() error { 280 - 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) 281 313 return nil 282 314 }, 283 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 -9
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": {}, 42 - }, 43 - li: { 44 - "@apply inline-block w-full my-0 py-0": {}, 45 - }, 46 - "ul, ol": { 47 - "@apply my-1 py-0": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 48 40 }, 49 41 code: { 50 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": {},
test_file

This is a binary file and will not be displayed.

+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 - }
+125
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 RepoNotFoundError = NewXrpcError( 60 + WithTag("RepoNotFound"), 61 + WithMessage("failed to access repository"), 62 + ) 63 + 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 69 + var AuthError = func(err error) XrpcError { 70 + return NewXrpcError( 71 + WithTag("Auth"), 72 + WithError(fmt.Errorf("signature verification failed: %w", err)), 73 + ) 74 + } 75 + 76 + var InvalidRepoError = func(r string) XrpcError { 77 + return NewXrpcError( 78 + WithTag("InvalidRepo"), 79 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 80 + ) 81 + } 82 + 83 + var GitError = func(e error) XrpcError { 84 + return NewXrpcError( 85 + WithTag("Git"), 86 + WithError(fmt.Errorf("git error: %w", e)), 87 + ) 88 + } 89 + 90 + var AccessControlError = func(d string) XrpcError { 91 + return NewXrpcError( 92 + WithTag("AccessControl"), 93 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 94 + ) 95 + } 96 + 97 + var RepoExistsError = func(r string) XrpcError { 98 + return NewXrpcError( 99 + WithTag("RepoExists"), 100 + WithError(fmt.Errorf("repo already exists: %s", r)), 101 + ) 102 + } 103 + 104 + var RecordExistsError = func(r string) XrpcError { 105 + return NewXrpcError( 106 + WithTag("RecordExists"), 107 + WithError(fmt.Errorf("repo already exists: %s", r)), 108 + ) 109 + } 110 + 111 + func GenericError(err error) XrpcError { 112 + return NewXrpcError( 113 + WithTag("Generic"), 114 + WithError(err), 115 + ) 116 + } 117 + 118 + func Unmarshal(errStr string) (XrpcError, error) { 119 + var xerr XrpcError 120 + err := json.Unmarshal([]byte(errStr), &xerr) 121 + if err != nil { 122 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 123 + } 124 + return xerr, nil 125 + }
+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 + }