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

Compare changes

Choose any two refs to compare.

Changed files
+9198 -5875
api
appview
config
db
issues
knots
middleware
oauth
handler
pages
posthog
pulls
repo
serververify
spindles
state
strings
validator
xrpcclient
cmd
appview
docs
knotclient
knotserver
legal
lexicons
nix
patchutil
spindle
xrpc
errors
+388 -732
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 ··· 1965 1785 } 1966 1786 1967 1787 t.Size = int64(extraI) 1788 + } 1789 + 1790 + default: 1791 + // Field doesn't exist on this type, so ignore it 1792 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1793 + return err 1794 + } 1795 + } 1796 + } 1797 + 1798 + return nil 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 1968 } 1969 1969 1970 1970 default: ··· 5642 5642 } 5643 5643 5644 5644 cw := cbg.NewCborWriter(w) 5645 - fieldCount := 7 5645 + fieldCount := 5 5646 5646 5647 5647 if t.Body == nil { 5648 5648 fieldCount-- ··· 5726 5726 return err 5727 5727 } 5728 5728 5729 - // t.Owner (string) (string) 5730 - if len("owner") > 1000000 { 5731 - return xerrors.Errorf("Value in field \"owner\" was too long") 5732 - } 5733 - 5734 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5735 - return err 5736 - } 5737 - if _, err := cw.WriteString(string("owner")); err != nil { 5738 - return err 5739 - } 5740 - 5741 - if len(t.Owner) > 1000000 { 5742 - return xerrors.Errorf("Value in field t.Owner was too long") 5743 - } 5744 - 5745 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil { 5746 - return err 5747 - } 5748 - if _, err := cw.WriteString(string(t.Owner)); err != nil { 5749 - return err 5750 - } 5751 - 5752 5729 // t.Title (string) (string) 5753 5730 if len("title") > 1000000 { 5754 5731 return xerrors.Errorf("Value in field \"title\" was too long") ··· 5772 5749 return err 5773 5750 } 5774 5751 5775 - // t.IssueId (int64) (int64) 5776 - if len("issueId") > 1000000 { 5777 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5778 - } 5779 - 5780 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 5781 - return err 5782 - } 5783 - if _, err := cw.WriteString(string("issueId")); err != nil { 5784 - return err 5785 - } 5786 - 5787 - if t.IssueId >= 0 { 5788 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 5789 - return err 5790 - } 5791 - } else { 5792 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 5793 - return err 5794 - } 5795 - } 5796 - 5797 5752 // t.CreatedAt (string) (string) 5798 5753 if len("createdAt") > 1000000 { 5799 5754 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5903 5858 5904 5859 t.LexiconTypeID = string(sval) 5905 5860 } 5906 - // t.Owner (string) (string) 5907 - case "owner": 5908 - 5909 - { 5910 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 5911 - if err != nil { 5912 - return err 5913 - } 5914 - 5915 - t.Owner = string(sval) 5916 - } 5917 5861 // t.Title (string) (string) 5918 5862 case "title": 5919 5863 ··· 5925 5869 5926 5870 t.Title = string(sval) 5927 5871 } 5928 - // t.IssueId (int64) (int64) 5929 - case "issueId": 5930 - { 5931 - maj, extra, err := cr.ReadHeader() 5932 - if err != nil { 5933 - return err 5934 - } 5935 - var extraI int64 5936 - switch maj { 5937 - case cbg.MajUnsignedInt: 5938 - extraI = int64(extra) 5939 - if extraI < 0 { 5940 - return fmt.Errorf("int64 positive overflow") 5941 - } 5942 - case cbg.MajNegativeInt: 5943 - extraI = int64(extra) 5944 - if extraI < 0 { 5945 - return fmt.Errorf("int64 negative overflow") 5946 - } 5947 - extraI = -1 - extraI 5948 - default: 5949 - return fmt.Errorf("wrong type for int64 field: %d", maj) 5950 - } 5951 - 5952 - t.IssueId = int64(extraI) 5953 - } 5954 5872 // t.CreatedAt (string) (string) 5955 5873 case "createdAt": 5956 5874 ··· 5980 5898 } 5981 5899 5982 5900 cw := cbg.NewCborWriter(w) 5983 - fieldCount := 7 5984 - 5985 - if t.CommentId == nil { 5986 - fieldCount-- 5987 - } 5901 + fieldCount := 5 5988 5902 5989 - if t.Owner == nil { 5990 - fieldCount-- 5991 - } 5992 - 5993 - if t.Repo == nil { 5903 + if t.ReplyTo == nil { 5994 5904 fieldCount-- 5995 5905 } 5996 5906 ··· 6021 5931 return err 6022 5932 } 6023 5933 6024 - // t.Repo (string) (string) 6025 - if t.Repo != nil { 6026 - 6027 - if len("repo") > 1000000 { 6028 - return xerrors.Errorf("Value in field \"repo\" was too long") 6029 - } 6030 - 6031 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 6032 - return err 6033 - } 6034 - if _, err := cw.WriteString(string("repo")); err != nil { 6035 - return err 6036 - } 6037 - 6038 - if t.Repo == nil { 6039 - if _, err := cw.Write(cbg.CborNull); err != nil { 6040 - return err 6041 - } 6042 - } else { 6043 - if len(*t.Repo) > 1000000 { 6044 - return xerrors.Errorf("Value in field t.Repo was too long") 6045 - } 6046 - 6047 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 6048 - return err 6049 - } 6050 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 6051 - return err 6052 - } 6053 - } 6054 - } 6055 - 6056 5934 // t.LexiconTypeID (string) (string) 6057 5935 if len("$type") > 1000000 { 6058 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6095 5973 return err 6096 5974 } 6097 5975 6098 - // t.Owner (string) (string) 6099 - if t.Owner != nil { 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 6100 5978 6101 - if len("owner") > 1000000 { 6102 - 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") 6103 5981 } 6104 5982 6105 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 6106 5984 return err 6107 5985 } 6108 - if _, err := cw.WriteString(string("owner")); err != nil { 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 6109 5987 return err 6110 5988 } 6111 5989 6112 - if t.Owner == nil { 5990 + if t.ReplyTo == nil { 6113 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 6114 5992 return err 6115 5993 } 6116 5994 } else { 6117 - if len(*t.Owner) > 1000000 { 6118 - 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") 6119 5997 } 6120 5998 6121 - 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 { 6122 6000 return err 6123 6001 } 6124 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6125 6003 return err 6126 6004 } 6127 6005 } 6128 6006 } 6129 6007 6130 - // t.CommentId (int64) (int64) 6131 - if t.CommentId != nil { 6132 - 6133 - if len("commentId") > 1000000 { 6134 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6135 - } 6136 - 6137 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6138 - return err 6139 - } 6140 - if _, err := cw.WriteString(string("commentId")); err != nil { 6141 - return err 6142 - } 6143 - 6144 - if t.CommentId == nil { 6145 - if _, err := cw.Write(cbg.CborNull); err != nil { 6146 - return err 6147 - } 6148 - } else { 6149 - if *t.CommentId >= 0 { 6150 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6151 - return err 6152 - } 6153 - } else { 6154 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6155 - return err 6156 - } 6157 - } 6158 - } 6159 - 6160 - } 6161 - 6162 6008 // t.CreatedAt (string) (string) 6163 6009 if len("createdAt") > 1000000 { 6164 6010 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6236 6082 6237 6083 t.Body = string(sval) 6238 6084 } 6239 - // t.Repo (string) (string) 6240 - case "repo": 6241 - 6242 - { 6243 - b, err := cr.ReadByte() 6244 - if err != nil { 6245 - return err 6246 - } 6247 - if b != cbg.CborNull[0] { 6248 - if err := cr.UnreadByte(); err != nil { 6249 - return err 6250 - } 6251 - 6252 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6253 - if err != nil { 6254 - return err 6255 - } 6256 - 6257 - t.Repo = (*string)(&sval) 6258 - } 6259 - } 6260 6085 // t.LexiconTypeID (string) (string) 6261 6086 case "$type": 6262 6087 ··· 6279 6104 6280 6105 t.Issue = string(sval) 6281 6106 } 6282 - // t.Owner (string) (string) 6283 - case "owner": 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6284 6109 6285 6110 { 6286 6111 b, err := cr.ReadByte() ··· 6297 6122 return err 6298 6123 } 6299 6124 6300 - t.Owner = (*string)(&sval) 6301 - } 6302 - } 6303 - // t.CommentId (int64) (int64) 6304 - case "commentId": 6305 - { 6306 - 6307 - b, err := cr.ReadByte() 6308 - if err != nil { 6309 - return err 6310 - } 6311 - if b != cbg.CborNull[0] { 6312 - if err := cr.UnreadByte(); err != nil { 6313 - return err 6314 - } 6315 - maj, extra, err := cr.ReadHeader() 6316 - if err != nil { 6317 - return err 6318 - } 6319 - var extraI int64 6320 - switch maj { 6321 - case cbg.MajUnsignedInt: 6322 - extraI = int64(extra) 6323 - if extraI < 0 { 6324 - return fmt.Errorf("int64 positive overflow") 6325 - } 6326 - case cbg.MajNegativeInt: 6327 - extraI = int64(extra) 6328 - if extraI < 0 { 6329 - return fmt.Errorf("int64 negative overflow") 6330 - } 6331 - extraI = -1 - extraI 6332 - default: 6333 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6334 - } 6335 - 6336 - t.CommentId = (*int64)(&extraI) 6125 + t.ReplyTo = (*string)(&sval) 6337 6126 } 6338 6127 } 6339 6128 // t.CreatedAt (string) (string) ··· 6529 6318 } 6530 6319 6531 6320 cw := cbg.NewCborWriter(w) 6532 - fieldCount := 9 6321 + fieldCount := 7 6533 6322 6534 6323 if t.Body == nil { 6535 6324 fieldCount-- ··· 6640 6429 return err 6641 6430 } 6642 6431 6643 - // t.PullId (int64) (int64) 6644 - if len("pullId") > 1000000 { 6645 - return xerrors.Errorf("Value in field \"pullId\" was too long") 6646 - } 6647 - 6648 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil { 6649 - return err 6650 - } 6651 - if _, err := cw.WriteString(string("pullId")); err != nil { 6652 - return err 6653 - } 6654 - 6655 - if t.PullId >= 0 { 6656 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil { 6657 - return err 6658 - } 6659 - } else { 6660 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil { 6661 - return err 6662 - } 6663 - } 6664 - 6665 6432 // t.Source (tangled.RepoPull_Source) (struct) 6666 6433 if t.Source != nil { 6667 6434 ··· 6681 6448 } 6682 6449 } 6683 6450 6684 - // t.CreatedAt (string) (string) 6685 - if len("createdAt") > 1000000 { 6686 - 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") 6687 6454 } 6688 6455 6689 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6456 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil { 6690 6457 return err 6691 6458 } 6692 - if _, err := cw.WriteString(string("createdAt")); err != nil { 6459 + if _, err := cw.WriteString(string("target")); err != nil { 6693 6460 return err 6694 6461 } 6695 6462 6696 - if len(t.CreatedAt) > 1000000 { 6697 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 6698 - } 6699 - 6700 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6701 - return err 6702 - } 6703 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6463 + if err := t.Target.MarshalCBOR(cw); err != nil { 6704 6464 return err 6705 6465 } 6706 6466 6707 - // t.TargetRepo (string) (string) 6708 - if len("targetRepo") > 1000000 { 6709 - 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") 6710 6470 } 6711 6471 6712 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil { 6472 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6713 6473 return err 6714 6474 } 6715 - if _, err := cw.WriteString(string("targetRepo")); err != nil { 6475 + if _, err := cw.WriteString(string("createdAt")); err != nil { 6716 6476 return err 6717 6477 } 6718 6478 6719 - if len(t.TargetRepo) > 1000000 { 6720 - return xerrors.Errorf("Value in field t.TargetRepo was too long") 6479 + if len(t.CreatedAt) > 1000000 { 6480 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 6721 6481 } 6722 6482 6723 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil { 6483 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6724 6484 return err 6725 6485 } 6726 - if _, err := cw.WriteString(string(t.TargetRepo)); err != nil { 6727 - return err 6728 - } 6729 - 6730 - // t.TargetBranch (string) (string) 6731 - if len("targetBranch") > 1000000 { 6732 - return xerrors.Errorf("Value in field \"targetBranch\" was too long") 6733 - } 6734 - 6735 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil { 6736 - return err 6737 - } 6738 - if _, err := cw.WriteString(string("targetBranch")); err != nil { 6739 - return err 6740 - } 6741 - 6742 - if len(t.TargetBranch) > 1000000 { 6743 - return xerrors.Errorf("Value in field t.TargetBranch was too long") 6744 - } 6745 - 6746 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil { 6747 - return err 6748 - } 6749 - if _, err := cw.WriteString(string(t.TargetBranch)); err != nil { 6486 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6750 6487 return err 6751 6488 } 6752 6489 return nil ··· 6777 6514 6778 6515 n := extra 6779 6516 6780 - nameBuf := make([]byte, 12) 6517 + nameBuf := make([]byte, 9) 6781 6518 for i := uint64(0); i < n; i++ { 6782 6519 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6783 6520 if err != nil { ··· 6847 6584 6848 6585 t.Title = string(sval) 6849 6586 } 6850 - // t.PullId (int64) (int64) 6851 - case "pullId": 6852 - { 6853 - maj, extra, err := cr.ReadHeader() 6854 - if err != nil { 6855 - return err 6856 - } 6857 - var extraI int64 6858 - switch maj { 6859 - case cbg.MajUnsignedInt: 6860 - extraI = int64(extra) 6861 - if extraI < 0 { 6862 - return fmt.Errorf("int64 positive overflow") 6863 - } 6864 - case cbg.MajNegativeInt: 6865 - extraI = int64(extra) 6866 - if extraI < 0 { 6867 - return fmt.Errorf("int64 negative overflow") 6868 - } 6869 - extraI = -1 - extraI 6870 - default: 6871 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6872 - } 6873 - 6874 - t.PullId = int64(extraI) 6875 - } 6876 6587 // t.Source (tangled.RepoPull_Source) (struct) 6877 6588 case "source": 6878 6589 ··· 6893 6604 } 6894 6605 6895 6606 } 6896 - // t.CreatedAt (string) (string) 6897 - case "createdAt": 6607 + // t.Target (tangled.RepoPull_Target) (struct) 6608 + case "target": 6898 6609 6899 6610 { 6900 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6611 + 6612 + b, err := cr.ReadByte() 6901 6613 if err != nil { 6902 6614 return err 6903 6615 } 6904 - 6905 - t.CreatedAt = string(sval) 6906 - } 6907 - // t.TargetRepo (string) (string) 6908 - case "targetRepo": 6909 - 6910 - { 6911 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6912 - if err != nil { 6913 - return err 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 + } 6914 6624 } 6915 6625 6916 - t.TargetRepo = string(sval) 6917 6626 } 6918 - // t.TargetBranch (string) (string) 6919 - case "targetBranch": 6627 + // t.CreatedAt (string) (string) 6628 + case "createdAt": 6920 6629 6921 6630 { 6922 6631 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 6924 6633 return err 6925 6634 } 6926 6635 6927 - t.TargetBranch = string(sval) 6636 + t.CreatedAt = string(sval) 6928 6637 } 6929 6638 6930 6639 default: ··· 6944 6653 } 6945 6654 6946 6655 cw := cbg.NewCborWriter(w) 6947 - fieldCount := 7 6948 6656 6949 - if t.CommentId == nil { 6950 - fieldCount-- 6951 - } 6952 - 6953 - if t.Owner == nil { 6954 - fieldCount-- 6955 - } 6956 - 6957 - if t.Repo == nil { 6958 - fieldCount-- 6959 - } 6960 - 6961 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6657 + if _, err := cw.Write([]byte{164}); err != nil { 6962 6658 return err 6963 6659 } 6964 6660 ··· 7008 6704 return err 7009 6705 } 7010 6706 7011 - // t.Repo (string) (string) 7012 - if t.Repo != nil { 7013 - 7014 - if len("repo") > 1000000 { 7015 - return xerrors.Errorf("Value in field \"repo\" was too long") 7016 - } 7017 - 7018 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7019 - return err 7020 - } 7021 - if _, err := cw.WriteString(string("repo")); err != nil { 7022 - return err 7023 - } 7024 - 7025 - if t.Repo == nil { 7026 - if _, err := cw.Write(cbg.CborNull); err != nil { 7027 - return err 7028 - } 7029 - } else { 7030 - if len(*t.Repo) > 1000000 { 7031 - return xerrors.Errorf("Value in field t.Repo was too long") 7032 - } 7033 - 7034 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 7035 - return err 7036 - } 7037 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 7038 - return err 7039 - } 7040 - } 7041 - } 7042 - 7043 6707 // t.LexiconTypeID (string) (string) 7044 6708 if len("$type") > 1000000 { 7045 6709 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 7059 6723 return err 7060 6724 } 7061 6725 7062 - // t.Owner (string) (string) 7063 - if t.Owner != nil { 7064 - 7065 - if len("owner") > 1000000 { 7066 - return xerrors.Errorf("Value in field \"owner\" was too long") 7067 - } 7068 - 7069 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 7070 - return err 7071 - } 7072 - if _, err := cw.WriteString(string("owner")); err != nil { 7073 - return err 7074 - } 7075 - 7076 - if t.Owner == nil { 7077 - if _, err := cw.Write(cbg.CborNull); err != nil { 7078 - return err 7079 - } 7080 - } else { 7081 - if len(*t.Owner) > 1000000 { 7082 - return xerrors.Errorf("Value in field t.Owner was too long") 7083 - } 7084 - 7085 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 7086 - return err 7087 - } 7088 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 7089 - return err 7090 - } 7091 - } 7092 - } 7093 - 7094 - // t.CommentId (int64) (int64) 7095 - if t.CommentId != nil { 7096 - 7097 - if len("commentId") > 1000000 { 7098 - return xerrors.Errorf("Value in field \"commentId\" was too long") 7099 - } 7100 - 7101 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 7102 - return err 7103 - } 7104 - if _, err := cw.WriteString(string("commentId")); err != nil { 7105 - return err 7106 - } 7107 - 7108 - if t.CommentId == nil { 7109 - if _, err := cw.Write(cbg.CborNull); err != nil { 7110 - return err 7111 - } 7112 - } else { 7113 - if *t.CommentId >= 0 { 7114 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 7115 - return err 7116 - } 7117 - } else { 7118 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 7119 - return err 7120 - } 7121 - } 7122 - } 7123 - 7124 - } 7125 - 7126 6726 // t.CreatedAt (string) (string) 7127 6727 if len("createdAt") > 1000000 { 7128 6728 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7211 6811 7212 6812 t.Pull = string(sval) 7213 6813 } 7214 - // t.Repo (string) (string) 7215 - case "repo": 7216 - 7217 - { 7218 - b, err := cr.ReadByte() 7219 - if err != nil { 7220 - return err 7221 - } 7222 - if b != cbg.CborNull[0] { 7223 - if err := cr.UnreadByte(); err != nil { 7224 - return err 7225 - } 7226 - 7227 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7228 - if err != nil { 7229 - return err 7230 - } 7231 - 7232 - t.Repo = (*string)(&sval) 7233 - } 7234 - } 7235 6814 // t.LexiconTypeID (string) (string) 7236 6815 case "$type": 7237 6816 ··· 7243 6822 7244 6823 t.LexiconTypeID = string(sval) 7245 6824 } 7246 - // t.Owner (string) (string) 7247 - case "owner": 7248 - 7249 - { 7250 - b, err := cr.ReadByte() 7251 - if err != nil { 7252 - return err 7253 - } 7254 - if b != cbg.CborNull[0] { 7255 - if err := cr.UnreadByte(); err != nil { 7256 - return err 7257 - } 7258 - 7259 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7260 - if err != nil { 7261 - return err 7262 - } 7263 - 7264 - t.Owner = (*string)(&sval) 7265 - } 7266 - } 7267 - // t.CommentId (int64) (int64) 7268 - case "commentId": 7269 - { 7270 - 7271 - b, err := cr.ReadByte() 7272 - if err != nil { 7273 - return err 7274 - } 7275 - if b != cbg.CborNull[0] { 7276 - if err := cr.UnreadByte(); err != nil { 7277 - return err 7278 - } 7279 - maj, extra, err := cr.ReadHeader() 7280 - if err != nil { 7281 - return err 7282 - } 7283 - var extraI int64 7284 - switch maj { 7285 - case cbg.MajUnsignedInt: 7286 - extraI = int64(extra) 7287 - if extraI < 0 { 7288 - return fmt.Errorf("int64 positive overflow") 7289 - } 7290 - case cbg.MajNegativeInt: 7291 - extraI = int64(extra) 7292 - if extraI < 0 { 7293 - return fmt.Errorf("int64 negative overflow") 7294 - } 7295 - extraI = -1 - extraI 7296 - default: 7297 - return fmt.Errorf("wrong type for int64 field: %d", maj) 7298 - } 7299 - 7300 - t.CommentId = (*int64)(&extraI) 7301 - } 7302 - } 7303 6825 // t.CreatedAt (string) (string) 7304 6826 case "createdAt": 7305 6827 ··· 7666 7188 } 7667 7189 7668 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) 7669 7325 } 7670 7326 7671 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 + }
+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 + }
+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 + }
-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 + }
+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 + }
+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 + }
+1 -1
appview/config/config.go
··· 21 21 AppPassword string `env:"APP_PASSWORD"` 22 22 23 23 // uhhhh this is because knot1 is under icy's did 24 - TmpAltAppPassword string `env:"ALT_APP_PASSWORD, required"` 24 + TmpAltAppPassword string `env:"ALT_APP_PASSWORD"` 25 25 } 26 26 27 27 type OAuthConfig struct {
+169
appview/db/db.go
··· 703 703 return err 704 704 }) 705 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 + 706 871 return &DB{db}, nil 707 872 } 708 873 ··· 747 912 } 748 913 749 914 return nil 915 + } 916 + 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 750 919 } 751 920 752 921 type filter struct {
+4 -4
appview/db/follow.go
··· 56 56 } 57 57 58 58 type FollowStats struct { 59 - Followers int 60 - Following int 59 + Followers int64 60 + Following int64 61 61 } 62 62 63 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 - followers, following := 0, 0 64 + var followers, following int64 65 65 err := e.QueryRow( 66 66 `SELECT 67 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 122 123 123 for rows.Next() { 124 124 var did string 125 - var followers, following int 125 + var followers, following int64 126 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 127 return nil, err 128 128 }
+430 -368
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 6 9 "strings" 7 10 "time" 8 11 ··· 12 15 ) 13 16 14 17 type Issue struct { 15 - ID int64 16 - RepoAt syntax.ATURI 17 - OwnerDid string 18 - IssueId int 19 - Rkey string 20 - Created time.Time 21 - Title string 22 - Body string 23 - 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 24 29 25 30 // optionally, populate this when querying for reverse mappings 26 31 // like comment counts, parent repo etc. 27 - Metadata *IssueMetadata 32 + Comments []IssueComment 33 + Repo *Repo 28 34 } 29 35 30 - type IssueMetadata struct { 31 - CommentCount int 32 - Repo *Repo 33 - // 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)) 34 38 } 35 39 36 - type Comment struct { 37 - OwnerDid string 38 - RepoAt syntax.ATURI 39 - Rkey string 40 - Issue int 41 - CommentId int 42 - Body string 43 - Created *time.Time 44 - Deleted *time.Time 45 - Edited *time.Time 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 46 47 } 47 48 48 - func (i *Issue) AtUri() syntax.ATURI { 49 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 + } 55 + 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 50 59 } 51 60 52 - func NewIssue(tx *sql.Tx, issue *Issue) error { 53 - 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 54 65 55 - _, err := tx.Exec(` 56 - insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 57 - values (?, 1) 58 - `, issue.RepoAt) 59 - if err != nil { 60 - 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 + } 61 75 } 62 76 63 - var nextId int 64 - err = tx.QueryRow(` 65 - update repo_issue_seqs 66 - set next_issue_id = next_issue_id + 1 67 - where repo_at = ? 68 - returning next_issue_id - 1 69 - `, issue.RepoAt).Scan(&nextId) 70 - if err != nil { 71 - return err 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 72 82 } 73 83 74 - issue.IssueId = nextId 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 + } 75 88 76 - res, err := tx.Exec(` 77 - insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 78 - values (?, ?, ?, ?, ?, ?, ?) 79 - `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 80 - if err != nil { 81 - 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 + }) 82 100 } 83 101 84 - lastID, err := res.LastInsertId() 102 + return listing 103 + } 104 + 105 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 85 107 if err != nil { 86 - return err 108 + created = time.Now() 87 109 } 88 - issue.ID = lastID 89 110 90 - if err := tx.Commit(); err != nil { 91 - return err 111 + body := "" 112 + if record.Body != nil { 113 + body = *record.Body 92 114 } 93 115 94 - return nil 116 + return Issue{ 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 124 + } 95 125 } 96 126 97 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 98 - var issueAt string 99 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 100 - return issueAt, err 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 101 137 } 102 138 103 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 104 - var ownerDid string 105 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 106 - return ownerDid, err 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 107 141 } 108 142 109 - func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 110 - var issues []Issue 111 - openValue := 0 112 - if isOpen { 113 - openValue = 1 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, 114 149 } 150 + } 115 151 116 - rows, err := e.Query( 117 - ` 118 - with numbered_issue as ( 119 - select 120 - i.id, 121 - i.owner_did, 122 - i.rkey, 123 - i.issue_id, 124 - i.created, 125 - i.title, 126 - i.body, 127 - i.open, 128 - count(c.id) as comment_count, 129 - row_number() over (order by i.created desc) as row_num 130 - from 131 - issues i 132 - left join 133 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 134 - where 135 - i.repo_at = ? and i.open = ? 136 - group by 137 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 138 - ) 139 - select 140 - id, 141 - owner_did, 142 - rkey, 143 - issue_id, 144 - created, 145 - title, 146 - body, 147 - open, 148 - comment_count 149 - from 150 - numbered_issue 151 - where 152 - row_num between ? and ?`, 153 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 154 + } 155 + 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 154 158 if err != nil { 159 + created = time.Now() 160 + } 161 + 162 + ownerDid := did 163 + 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 155 165 return nil, err 156 166 } 157 - defer rows.Close() 158 167 159 - for rows.Next() { 160 - var issue Issue 161 - var createdAt string 162 - var metadata IssueMetadata 163 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 164 - if err != nil { 165 - return nil, err 166 - } 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 + } 176 + 177 + return &comment, nil 178 + } 167 179 168 - createdTime, err := time.Parse(time.RFC3339, createdAt) 169 - if err != nil { 170 - return nil, err 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 171 207 } 172 - issue.Created = createdTime 173 - issue.Metadata = &metadata 174 208 175 - issues = append(issues, issue) 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 176 212 } 213 + } 177 214 178 - if err := rows.Err(); err != nil { 179 - 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 180 226 } 181 227 182 - 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) 236 + } 237 + 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 183 246 } 184 247 185 - func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 186 - issues := make([]Issue, 0, limit) 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 187 250 188 251 var conditions []string 189 252 var args []any 253 + 190 254 for _, filter := range filters { 191 255 conditions = append(conditions, filter.Condition()) 192 256 args = append(args, filter.Arg()...) ··· 196 260 if conditions != nil { 197 261 whereClause = " where " + strings.Join(conditions, " and ") 198 262 } 199 - limitClause := "" 200 - if limit != 0 { 201 - limitClause = fmt.Sprintf(" limit %d ", limit) 202 - } 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() 203 270 204 271 query := fmt.Sprintf( 205 - `select 206 - i.id, 207 - i.owner_did, 208 - i.repo_at, 209 - i.issue_id, 210 - i.created, 211 - i.title, 212 - i.body, 213 - i.open 214 - from 215 - issues i 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 216 291 %s 217 - order by 218 - i.created desc 219 - %s`, 220 - whereClause, limitClause) 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 221 296 222 297 rows, err := e.Query(query, args...) 223 298 if err != nil { 224 - return nil, err 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 225 300 } 226 301 defer rows.Close() 227 302 228 303 for rows.Next() { 229 304 var issue Issue 230 - var issueCreatedAt string 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 231 308 err := rows.Scan( 232 - &issue.ID, 233 - &issue.OwnerDid, 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 234 312 &issue.RepoAt, 235 313 &issue.IssueId, 236 - &issueCreatedAt, 237 314 &issue.Title, 238 315 &issue.Body, 239 316 &issue.Open, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 240 321 ) 241 322 if err != nil { 242 - return nil, err 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 243 324 } 244 325 245 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 246 - if err != nil { 247 - return nil, err 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 248 328 } 249 - issue.Created = issueCreatedTime 250 329 251 - issues = append(issues, issue) 252 - } 253 - 254 - if err := rows.Err(); err != nil { 255 - return nil, err 256 - } 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 334 + } 257 335 258 - return issues, nil 259 - } 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 340 + } 260 341 261 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 262 - return GetIssuesWithLimit(e, 0, filters...) 263 - } 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 344 + } 264 345 265 - // timeframe here is directly passed into the sql query filter, and any 266 - // timeframe in the past should be negative; e.g.: "-3 months" 267 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 268 - var issues []Issue 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 + } 269 351 270 - rows, err := e.Query( 271 - `select 272 - i.id, 273 - i.owner_did, 274 - i.rkey, 275 - i.repo_at, 276 - i.issue_id, 277 - i.created, 278 - i.title, 279 - i.body, 280 - i.open, 281 - r.did, 282 - r.name, 283 - r.knot, 284 - r.rkey, 285 - r.created 286 - from 287 - issues i 288 - join 289 - repos r on i.repo_at = r.at_uri 290 - where 291 - i.owner_did = ? and i.created >= date ('now', ?) 292 - order by 293 - i.created desc`, 294 - ownerDid, timeframe) 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 295 353 if err != nil { 296 - return nil, err 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 297 355 } 298 - defer rows.Close() 299 356 300 - for rows.Next() { 301 - var issue Issue 302 - var issueCreatedAt, repoCreatedAt string 303 - var repo Repo 304 - err := rows.Scan( 305 - &issue.ID, 306 - &issue.OwnerDid, 307 - &issue.Rkey, 308 - &issue.RepoAt, 309 - &issue.IssueId, 310 - &issueCreatedAt, 311 - &issue.Title, 312 - &issue.Body, 313 - &issue.Open, 314 - &repo.Did, 315 - &repo.Name, 316 - &repo.Knot, 317 - &repo.Rkey, 318 - &repoCreatedAt, 319 - ) 320 - if err != nil { 321 - return nil, err 322 - } 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 323 361 324 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 325 - if err != nil { 326 - return nil, err 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) 327 369 } 328 - issue.Created = issueCreatedTime 370 + } 329 371 330 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 331 - if err != nil { 332 - return nil, err 333 - } 334 - repo.Created = repoCreatedTime 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) 377 + } 335 378 336 - issue.Metadata = &IssueMetadata{ 337 - Repo: &repo, 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]) 338 383 } 384 + } 339 385 340 - issues = append(issues, issue) 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 341 389 } 342 390 343 - if err := rows.Err(); err != nil { 344 - return nil, err 345 - } 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 346 394 347 395 return issues, nil 396 + } 397 + 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 348 400 } 349 401 350 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { ··· 353 405 354 406 var issue Issue 355 407 var createdAt string 356 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 357 409 if err != nil { 358 410 return nil, err 359 411 } ··· 367 419 return &issue, nil 368 420 } 369 421 370 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 371 - query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 372 - row := e.QueryRow(query, repoAt, issueId) 373 - 374 - var issue Issue 375 - var createdAt string 376 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 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 + ) 377 453 if err != nil { 378 - return nil, nil, err 454 + return 0, err 379 455 } 380 456 381 - createdTime, err := time.Parse(time.RFC3339, createdAt) 457 + id, err := result.LastInsertId() 382 458 if err != nil { 383 - return nil, nil, err 459 + return 0, err 384 460 } 385 - issue.Created = createdTime 386 461 387 - comments, err := GetComments(e, repoAt, issueId) 388 - if err != nil { 389 - 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()...) 390 471 } 391 472 392 - return &issue, comments, nil 393 - } 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 394 477 395 - func NewIssueComment(e Execer, comment *Comment) error { 396 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 397 - _, err := e.Exec( 398 - query, 399 - comment.OwnerDid, 400 - comment.RepoAt, 401 - comment.Rkey, 402 - comment.Issue, 403 - comment.CommentId, 404 - comment.Body, 405 - ) 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...) 406 481 return err 407 482 } 408 483 409 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 410 - 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 + } 411 493 412 - rows, err := e.Query(` 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 498 + 499 + query := fmt.Sprintf(` 413 500 select 414 - owner_did, 415 - issue_id, 416 - comment_id, 501 + id, 502 + did, 417 503 rkey, 504 + issue_at, 505 + reply_to, 418 506 body, 419 507 created, 420 508 edited, 421 509 deleted 422 510 from 423 - comments 424 - where 425 - repo_at = ? and issue_id = ? 426 - order by 427 - created asc`, 428 - repoAt, 429 - issueId, 430 - ) 431 - if err == sql.ErrNoRows { 432 - return []Comment{}, nil 433 - } 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 434 516 if err != nil { 435 517 return nil, err 436 518 } 437 - defer rows.Close() 438 519 439 520 for rows.Next() { 440 - var comment Comment 441 - var createdAt string 442 - var deletedAt, editedAt, rkey sql.NullString 443 - 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 + ) 444 535 if err != nil { 445 536 return nil, err 446 537 } 447 538 448 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 449 - if err != nil { 450 - 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 451 542 } 452 - comment.Created = &createdAtTime 453 543 454 - if deletedAt.Valid { 455 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 456 - if err != nil { 457 - 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 458 551 } 459 - comment.Deleted = &deletedTime 460 552 } 461 553 462 - if editedAt.Valid { 463 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 464 - if err != nil { 465 - return nil, err 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 466 557 } 467 - comment.Edited = &editedTime 468 558 } 469 559 470 - if rkey.Valid { 471 - comment.Rkey = rkey.String 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 472 562 } 473 563 474 564 comments = append(comments, comment) 475 565 } 476 566 477 - if err := rows.Err(); err != nil { 567 + if err = rows.Err(); err != nil { 478 568 return nil, err 479 569 } 480 570 481 571 return comments, nil 482 572 } 483 573 484 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 485 - query := ` 486 - select 487 - owner_did, body, rkey, created, deleted, edited 488 - from 489 - comments where repo_at = ? and issue_id = ? and comment_id = ? 490 - ` 491 - row := e.QueryRow(query, repoAt, issueId, commentId) 492 - 493 - var comment Comment 494 - var createdAt string 495 - var deletedAt, editedAt, rkey sql.NullString 496 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 497 - if err != nil { 498 - 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()...) 499 580 } 500 581 501 - createdTime, err := time.Parse(time.RFC3339, createdAt) 502 - if err != nil { 503 - return nil, err 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 504 585 } 505 - comment.Created = &createdTime 506 586 507 - if deletedAt.Valid { 508 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 509 - if err != nil { 510 - return nil, err 511 - } 512 - comment.Deleted = &deletedTime 513 - } 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 514 591 515 - if editedAt.Valid { 516 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 517 - if err != nil { 518 - return nil, err 519 - } 520 - 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()...) 521 598 } 522 599 523 - if rkey.Valid { 524 - comment.Rkey = rkey.String 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 525 603 } 526 604 527 - comment.RepoAt = repoAt 528 - comment.Issue = issueId 529 - comment.CommentId = commentId 530 - 531 - return &comment, nil 532 - } 533 - 534 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 535 - _, err := e.Exec( 536 - ` 537 - update comments 538 - set body = ?, 539 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 540 - where repo_at = ? and issue_id = ? and comment_id = ? 541 - `, newBody, repoAt, issueId, commentId) 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 542 607 return err 543 608 } 544 609 545 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 546 - _, err := e.Exec( 547 - ` 548 - update comments 549 - set body = "", 550 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 551 - where repo_at = ? and issue_id = ? and comment_id = ? 552 - `, repoAt, issueId, commentId) 553 - return err 554 - } 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 + } 555 617 556 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 557 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 558 - return err 559 - } 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 560 622 561 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 562 - _, 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...) 563 625 return err 564 626 } 565 627
+23 -5
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 } ··· 535 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 536 554 args = append(args, did, PullOpen) 537 555 case VanityStatOpenIssueCount: 538 - query = `select count(id) from issues where owner_did = ? and open = 1` 556 + query = `select count(id) from issues where did = ? and open = 1` 539 557 args = append(args, did) 540 558 case VanityStatClosedIssueCount: 541 - query = `select count(id) from issues where owner_did = ? and open = 0` 559 + query = `select count(id) from issues where did = ? and open = 0` 542 560 args = append(args, did) 543 561 case VanityStatRepositoryCount: 544 562 query = `select count(id) from repos where did = ?` ··· 572 590 } 573 591 574 592 // ensure all pinned repos are either own repos or collaborating repos 575 - repos, err := GetAllReposByDid(e, profile.Did) 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 576 594 if err != nil { 577 595 log.Printf("getting repos for %s: %s", profile.Did, err) 578 596 }
+9 -8
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 }
+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)
+17 -17
appview/db/registration.go
··· 10 10 // Registration represents a knot registration. Knot would've been a better 11 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 18 - ReadOnly bool 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 19 19 } 20 20 21 21 func (r *Registration) Status() Status { 22 - if r.ReadOnly { 23 - return ReadOnly 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 24 24 } else if r.Registered != nil { 25 25 return Registered 26 26 } else { ··· 32 32 return r.Status() == Registered 33 33 } 34 34 35 - func (r *Registration) IsReadOnly() bool { 36 - return r.Status() == ReadOnly 35 + func (r *Registration) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 37 37 } 38 38 39 39 func (r *Registration) IsPending() bool { ··· 45 45 const ( 46 46 Registered Status = iota 47 47 Pending 48 - ReadOnly 48 + NeedsUpgrade 49 49 ) 50 50 51 51 func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { ··· 64 64 } 65 65 66 66 query := fmt.Sprintf(` 67 - select id, domain, did, created, registered, read_only 67 + select id, domain, did, created, registered, needs_upgrade 68 68 from registrations 69 69 %s 70 70 order by created ··· 80 80 for rows.Next() { 81 81 var createdAt string 82 82 var registeredAt sql.Null[string] 83 - var readOnly int 83 + var needsUpgrade int 84 84 var reg Registration 85 85 86 - err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 87 if err != nil { 88 88 return nil, err 89 89 } ··· 98 98 } 99 99 } 100 100 101 - if readOnly != 0 { 102 - reg.ReadOnly = true 101 + if needsUpgrade != 0 { 102 + reg.NeedsUpgrade = true 103 103 } 104 104 105 105 registrations = append(registrations, reg) ··· 116 116 args = append(args, filter.Arg()...) 117 117 } 118 118 119 - query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 120 if len(conditions) > 0 { 121 121 query += " where " + strings.Join(conditions, " and ") 122 122 }
+27 -130
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 "fmt" 6 7 "log" 7 8 "slices" ··· 36 37 func (r Repo) DidSlashRepo() string { 37 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 38 39 return p 39 - } 40 - 41 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 42 - var repos []Repo 43 - 44 - rows, err := e.Query( 45 - `select did, name, knot, rkey, description, created, source 46 - from repos 47 - order by created desc 48 - limit ? 49 - `, 50 - limit, 51 - ) 52 - if err != nil { 53 - return nil, err 54 - } 55 - defer rows.Close() 56 - 57 - for rows.Next() { 58 - var repo Repo 59 - err := scanRepo( 60 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 61 - ) 62 - if err != nil { 63 - return nil, err 64 - } 65 - repos = append(repos, repo) 66 - } 67 - 68 - if err := rows.Err(); err != nil { 69 - return nil, err 70 - } 71 - 72 - return repos, nil 73 40 } 74 41 75 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 310 277 311 278 slices.SortFunc(repos, func(a, b Repo) int { 312 279 if a.Created.After(b.Created) { 313 - return 1 280 + return -1 314 281 } 315 - return -1 282 + return 1 316 283 }) 317 284 318 285 return repos, nil 319 286 } 320 287 321 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 322 - var repos []Repo 288 + func CountRepos(e Execer, filters ...filter) (int64, error) { 289 + var conditions []string 290 + var args []any 291 + for _, filter := range filters { 292 + conditions = append(conditions, filter.Condition()) 293 + args = append(args, filter.Arg()...) 294 + } 323 295 324 - rows, err := e.Query( 325 - `select 326 - r.did, 327 - r.name, 328 - r.knot, 329 - r.rkey, 330 - r.description, 331 - r.created, 332 - count(s.id) as star_count, 333 - r.source 334 - from 335 - repos r 336 - left join 337 - stars s on r.at_uri = s.repo_at 338 - where 339 - r.did = ? 340 - group by 341 - r.at_uri 342 - order by r.created desc`, 343 - did) 344 - if err != nil { 345 - return nil, err 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 346 299 } 347 - defer rows.Close() 348 300 349 - for rows.Next() { 350 - var repo Repo 351 - var repoStats RepoStats 352 - var createdAt string 353 - var nullableDescription sql.NullString 354 - var nullableSource sql.NullString 355 - 356 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 357 - if err != nil { 358 - return nil, err 359 - } 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 360 304 361 - if nullableDescription.Valid { 362 - repo.Description = nullableDescription.String 363 - } 364 - 365 - if nullableSource.Valid { 366 - repo.Source = nullableSource.String 367 - } 368 - 369 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 370 - if err != nil { 371 - repo.Created = time.Now() 372 - } else { 373 - repo.Created = createdAtTime 374 - } 375 - 376 - repo.RepoStats = &repoStats 377 - 378 - repos = append(repos, repo) 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 379 307 } 380 308 381 - if err := rows.Err(); err != nil { 382 - return nil, err 383 - } 384 - 385 - return repos, nil 309 + return count, nil 386 310 } 387 311 388 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 466 390 var repos []Repo 467 391 468 392 rows, err := e.Query( 469 - `select did, name, knot, rkey, description, created, source 470 - from repos 471 - where did = ? and source is not null and source != '' 472 - order by created desc`, 473 - 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, 474 401 ) 475 402 if err != nil { 476 403 return nil, err ··· 567 494 IssueCount IssueCount 568 495 PullCount PullCount 569 496 } 570 - 571 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 572 - var createdAt string 573 - var nullableDescription sql.NullString 574 - var nullableSource sql.NullString 575 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 576 - return err 577 - } 578 - 579 - if nullableDescription.Valid { 580 - *description = nullableDescription.String 581 - } else { 582 - *description = "" 583 - } 584 - 585 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 586 - if err != nil { 587 - *created = time.Now() 588 - } else { 589 - *created = createdAtTime 590 - } 591 - 592 - if nullableSource.Valid { 593 - *source = nullableSource.String 594 - } else { 595 - *source = "" 596 - } 597 - 598 - return nil 599 - }
+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 {
+26
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" ··· 181 183 } 182 184 183 185 return stars, nil 186 + } 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 184 210 } 185 211 186 212 func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
··· 206 206 return all, nil 207 207 } 208 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 231 + } 232 + 209 233 func DeleteString(e Execer, filters ...filter) error { 210 234 var conditions []string 211 235 var args []any
+12 -14
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 - const Limit = 50 24 - 25 23 // TODO: this gathers heterogenous events from different sources and aggregates 26 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 27 - func MakeTimeline(e Execer) ([]TimelineEvent, error) { 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 28 26 var events []TimelineEvent 29 27 30 - repos, err := getTimelineRepos(e) 28 + repos, err := getTimelineRepos(e, limit) 31 29 if err != nil { 32 30 return nil, err 33 31 } 34 32 35 - stars, err := getTimelineStars(e) 33 + stars, err := getTimelineStars(e, limit) 36 34 if err != nil { 37 35 return nil, err 38 36 } 39 37 40 - follows, err := getTimelineFollows(e) 38 + follows, err := getTimelineFollows(e, limit) 41 39 if err != nil { 42 40 return nil, err 43 41 } ··· 51 49 }) 52 50 53 51 // Limit the slice to 100 events 54 - if len(events) > Limit { 55 - events = events[:Limit] 52 + if len(events) > limit { 53 + events = events[:limit] 56 54 } 57 55 58 56 return events, nil 59 57 } 60 58 61 - func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 62 - repos, err := GetRepos(e, Limit) 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 63 61 if err != nil { 64 62 return nil, err 65 63 } ··· 104 102 return events, nil 105 103 } 106 104 107 - func getTimelineStars(e Execer) ([]TimelineEvent, error) { 108 - stars, err := GetStars(e, Limit) 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 109 107 if err != nil { 110 108 return nil, err 111 109 } ··· 131 129 return events, nil 132 130 } 133 131 134 - func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 136 134 if err != nil { 137 135 return nil, err 138 136 }
+133 -6
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" ··· 15 16 "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 17 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) 67 70 case tangled.KnotMemberNSID: 68 71 err = i.ingestKnotMember(e) 69 72 case tangled.KnotNSID: 70 73 err = i.ingestKnot(e) 71 74 case tangled.StringNSID: 72 75 err = i.ingestString(e) 76 + case tangled.RepoIssueNSID: 77 + err = i.ingestIssue(ctx, e) 78 + case tangled.RepoIssueCommentNSID: 79 + err = i.ingestIssueComment(e) 73 80 } 74 81 l = i.Logger.With("nsid", e.Commit.Collection) 75 82 } ··· 340 347 return nil 341 348 } 342 349 343 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 350 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 344 351 did := e.Did 345 352 var err error 346 353 ··· 363 370 return fmt.Errorf("failed to enforce permissions: %w", err) 364 371 } 365 372 366 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 373 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 367 374 if err != nil { 368 375 return err 369 376 } ··· 446 453 return nil 447 454 } 448 455 449 - func (i *Ingester) ingestSpindle(e *models.Event) error { 456 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 450 457 did := e.Did 451 458 var err error 452 459 ··· 479 486 return err 480 487 } 481 488 482 - err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 489 + err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 483 490 if err != nil { 484 491 l.Error("failed to add spindle to db", "err", err, "instance", instance) 485 492 return err ··· 769 776 770 777 return nil 771 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 + }
+477 -286
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 - "strings" 11 12 "time" 12 13 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 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" 17 18 ··· 21 22 "tangled.sh/tangled.sh/core/appview/notify" 22 23 "tangled.sh/tangled.sh/core/appview/oauth" 23 24 "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 28 31 "tangled.sh/tangled.sh/core/tid" 29 32 ) 30 33 ··· 36 39 db *db.DB 37 40 config *config.Config 38 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 39 44 } 40 45 41 46 func New( ··· 46 51 db *db.DB, 47 52 config *config.Config, 48 53 notifier notify.Notifier, 54 + validator *validator.Validator, 49 55 ) *Issues { 50 56 return &Issues{ 51 57 oauth: oauth, ··· 55 61 db: db, 56 62 config: config, 57 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 58 66 } 59 67 } 60 68 61 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 62 71 user := rp.oauth.GetUser(r) 63 72 f, err := rp.repoResolver.Resolve(r) 64 73 if err != nil { ··· 66 75 return 67 76 } 68 77 69 - issueId := chi.URLParam(r, "issue") 70 - issueIdInt, err := strconv.Atoi(issueId) 71 - if err != nil { 72 - http.Error(w, "bad issue id", http.StatusBadRequest) 73 - log.Println("failed to parse issue id", err) 74 - return 75 - } 76 - 77 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 78 - if err != nil { 79 - log.Println("failed to get issue and comments", err) 80 - 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) 81 82 return 82 83 } 83 84 84 85 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 86 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + l.Error("failed to get issue reactions", "err", err) 88 88 } 89 89 90 90 userReactions := map[db.ReactionKind]bool{} ··· 92 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 93 } 94 94 95 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 96 - if err != nil { 97 - log.Println("failed to resolve issue owner", err) 98 - } 99 - 100 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 101 - LoggedInUser: user, 102 - RepoInfo: f.RepoInfo(user), 103 - Issue: issue, 104 - Comments: comments, 105 - 106 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 107 - 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 108 100 OrderedReactionKinds: db.OrderedReactionKinds, 109 101 Reactions: reactionCountMap, 110 102 UserReacted: userReactions, 111 103 }) 112 - 113 104 } 114 105 115 - 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") 116 108 user := rp.oauth.GetUser(r) 117 109 f, err := rp.repoResolver.Resolve(r) 118 110 if err != nil { ··· 120 112 return 121 113 } 122 114 123 - issueId := chi.URLParam(r, "issue") 124 - issueIdInt, err := strconv.Atoi(issueId) 125 - if err != nil { 126 - http.Error(w, "bad issue id", http.StatusBadRequest) 127 - 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) 128 119 return 129 120 } 130 121 131 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 132 - if err != nil { 133 - log.Println("failed to get issue", err) 134 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 135 - return 136 - } 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") 137 134 138 - collaborators, err := f.Collaborators(r.Context()) 139 - if err != nil { 140 - log.Println("failed to fetch repo collaborators: %w", err) 141 - } 142 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 143 - return user.Did == collab.Did 144 - }) 145 - isIssueOwner := user.Did == issue.OwnerDid 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 + } 146 140 147 - // TODO: make this more granular 148 - if isIssueOwner || isCollaborator { 149 - 150 - closed := tangled.RepoIssueStateClosed 141 + newRecord := newIssue.AsRecord() 151 142 143 + // edit an atproto record 152 144 client, err := rp.oauth.AuthorizedClient(r) 153 145 if err != nil { 154 - 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.") 155 148 return 156 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 + 157 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 159 + Collection: tangled.RepoIssueNSID, 159 160 Repo: user.Did, 160 - Rkey: tid.TID(), 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 161 163 Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 164 + Val: &newRecord, 166 165 }, 167 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 + } 168 172 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 169 175 if err != nil { 170 - log.Println("failed to update issue state", err) 171 - 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.") 172 178 return 173 179 } 180 + defer tx.Rollback() 174 181 175 - 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 + ) 176 280 if err != nil { 177 281 log.Println("failed to close issue", err) 178 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 283 return 180 284 } 181 285 182 - 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)) 183 287 return 184 288 } else { 185 289 log.Println("user is not permitted to close issue") ··· 189 293 } 190 294 191 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 192 297 user := rp.oauth.GetUser(r) 193 298 f, err := rp.repoResolver.Resolve(r) 194 299 if err != nil { ··· 196 301 return 197 302 } 198 303 199 - issueId := chi.URLParam(r, "issue") 200 - issueIdInt, err := strconv.Atoi(issueId) 201 - if err != nil { 202 - http.Error(w, "bad issue id", http.StatusBadRequest) 203 - log.Println("failed to parse issue id", err) 204 - return 205 - } 206 - 207 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 208 - if err != nil { 209 - log.Println("failed to get issue", err) 210 - 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) 211 308 return 212 309 } 213 310 ··· 218 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 316 return user.Did == collab.Did 220 317 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 318 + isIssueOwner := user.Did == issue.Did 222 319 223 320 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 225 325 if err != nil { 226 326 log.Println("failed to reopen issue", err) 227 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 328 return 229 329 } 230 - 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)) 231 331 return 232 332 } else { 233 333 log.Println("user is not the owner of the repo") ··· 237 337 } 238 338 239 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 240 341 user := rp.oauth.GetUser(r) 241 342 f, err := rp.repoResolver.Resolve(r) 242 343 if err != nil { 243 - log.Println("failed to get repo and knot", err) 344 + l.Error("failed to get repo and knot", "err", err) 244 345 return 245 346 } 246 347 247 - issueId := chi.URLParam(r, "issue") 248 - issueIdInt, err := strconv.Atoi(issueId) 249 - if err != nil { 250 - http.Error(w, "bad issue id", http.StatusBadRequest) 251 - 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) 252 352 return 253 353 } 254 354 255 - switch r.Method { 256 - case http.MethodPost: 257 - body := r.FormValue("body") 258 - if body == "" { 259 - rp.pages.Notice(w, "issue", "Body is required") 260 - return 261 - } 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 262 360 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 265 366 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 - OwnerDid: user.Did, 268 - RepoAt: f.RepoAt(), 269 - Issue: issueIdInt, 270 - CommentId: commentId, 271 - Body: body, 272 - Rkey: rkey, 273 - }) 274 - if err != nil { 275 - log.Println("failed to create comment", err) 276 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 277 - return 278 - } 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() 279 381 280 - createdAt := time.Now().Format(time.RFC3339) 281 - commentIdInt64 := int64(commentId) 282 - ownerDid := user.Did 283 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 284 - if err != nil { 285 - log.Println("failed to get issue at", err) 286 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 287 - return 288 - } 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 + } 289 388 290 - atUri := f.RepoAt().String() 291 - client, err := rp.oauth.AuthorizedClient(r) 292 - if err != nil { 293 - log.Println("failed to get authorized client", err) 294 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 295 - 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) 296 407 } 297 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 298 - Collection: tangled.RepoIssueCommentNSID, 299 - Repo: user.Did, 300 - Rkey: rkey, 301 - Record: &lexutil.LexiconTypeDecoder{ 302 - Val: &tangled.RepoIssueComment{ 303 - Repo: &atUri, 304 - Issue: issueAt, 305 - CommentId: &commentIdInt64, 306 - Owner: &ownerDid, 307 - Body: body, 308 - CreatedAt: createdAt, 309 - }, 310 - }, 311 - }) 312 - if err != nil { 313 - log.Println("failed to create comment", err) 314 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 315 - return 316 - } 408 + }() 317 409 318 - 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.") 319 414 return 320 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)) 321 420 } 322 421 323 422 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 324 424 user := rp.oauth.GetUser(r) 325 425 f, err := rp.repoResolver.Resolve(r) 326 426 if err != nil { 327 - log.Println("failed to get repo and knot", err) 328 - return 329 - } 330 - 331 - issueId := chi.URLParam(r, "issue") 332 - issueIdInt, err := strconv.Atoi(issueId) 333 - if err != nil { 334 - http.Error(w, "bad issue id", http.StatusBadRequest) 335 - log.Println("failed to parse issue id", err) 427 + l.Error("failed to get repo and knot", "err", err) 336 428 return 337 429 } 338 430 339 - commentId := chi.URLParam(r, "comment_id") 340 - commentIdInt, err := strconv.Atoi(commentId) 341 - if err != nil { 342 - http.Error(w, "bad comment id", http.StatusBadRequest) 343 - 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) 344 435 return 345 436 } 346 437 347 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 348 443 if err != nil { 349 - log.Println("failed to get issue", err) 350 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 351 446 return 352 447 } 353 - 354 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 355 - if err != nil { 356 - http.Error(w, "bad comment id", http.StatusBadRequest) 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) 357 451 return 358 452 } 453 + comment := comments[0] 359 454 360 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 361 456 LoggedInUser: user, 362 457 RepoInfo: f.RepoInfo(user), 363 458 Issue: issue, 364 - Comment: comment, 459 + Comment: &comment, 365 460 }) 366 461 } 367 462 368 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 369 465 user := rp.oauth.GetUser(r) 370 466 f, err := rp.repoResolver.Resolve(r) 371 467 if err != nil { 372 - log.Println("failed to get repo and knot", err) 373 - return 374 - } 375 - 376 - issueId := chi.URLParam(r, "issue") 377 - issueIdInt, err := strconv.Atoi(issueId) 378 - if err != nil { 379 - http.Error(w, "bad issue id", http.StatusBadRequest) 380 - log.Println("failed to parse issue id", err) 468 + l.Error("failed to get repo and knot", "err", err) 381 469 return 382 470 } 383 471 384 - commentId := chi.URLParam(r, "comment_id") 385 - commentIdInt, err := strconv.Atoi(commentId) 386 - if err != nil { 387 - http.Error(w, "bad comment id", http.StatusBadRequest) 388 - 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) 389 476 return 390 477 } 391 478 392 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 393 484 if err != nil { 394 - log.Println("failed to get issue", err) 395 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 396 487 return 397 488 } 398 - 399 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 400 - if err != nil { 401 - http.Error(w, "bad comment id", http.StatusBadRequest) 489 + if len(comments) != 1 { 490 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 491 + http.Error(w, "invalid comment id", http.StatusBadRequest) 402 492 return 403 493 } 494 + comment := comments[0] 404 495 405 - if comment.OwnerDid != user.Did { 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 406 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 407 499 return 408 500 } ··· 413 505 LoggedInUser: user, 414 506 RepoInfo: f.RepoInfo(user), 415 507 Issue: issue, 416 - Comment: comment, 508 + Comment: &comment, 417 509 }) 418 510 case http.MethodPost: 419 511 // extract form value ··· 424 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 425 517 return 426 518 } 427 - rkey := comment.Rkey 519 + 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 428 525 429 - // optimistic update 430 - edited := time.Now() 431 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 526 + _, err = db.AddIssueComment(rp.db, newComment) 432 527 if err != nil { 433 528 log.Println("failed to perferom update-description query", err) 434 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 436 531 } 437 532 438 533 // rkey is optional, it was introduced later 439 - if comment.Rkey != "" { 534 + if newComment.Rkey != "" { 440 535 // update the record on pds 441 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 442 537 if err != nil { 443 - // failed to get record 444 - log.Println(err, rkey) 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 445 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 446 540 return 447 541 } 448 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 449 - record, _ := data.UnmarshalJSON(value) 450 - 451 - repoAt := record["repo"].(string) 452 - issueAt := record["issue"].(string) 453 - createdAt := record["createdAt"].(string) 454 - commentIdInt64 := int64(commentIdInt) 455 542 456 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 457 544 Collection: tangled.RepoIssueCommentNSID, 458 545 Repo: user.Did, 459 - Rkey: rkey, 546 + Rkey: newComment.Rkey, 460 547 SwapRecord: ex.Cid, 461 548 Record: &lexutil.LexiconTypeDecoder{ 462 - Val: &tangled.RepoIssueComment{ 463 - Repo: &repoAt, 464 - Issue: issueAt, 465 - CommentId: &commentIdInt64, 466 - Owner: &comment.OwnerDid, 467 - Body: newBody, 468 - CreatedAt: createdAt, 469 - }, 549 + Val: &record, 470 550 }, 471 551 }) 472 552 if err != nil { 473 - log.Println(err) 553 + l.Error("failed to update record on PDS", "err", err) 474 554 } 475 555 } 476 556 477 - // optimistic update for htmx 478 - comment.Body = newBody 479 - comment.Edited = &edited 480 - 481 557 // return new comment body with htmx 482 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 483 559 LoggedInUser: user, 484 560 RepoInfo: f.RepoInfo(user), 485 561 Issue: issue, 486 - Comment: comment, 562 + Comment: &newComment, 487 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) 488 573 return 574 + } 489 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 490 581 } 491 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 + }) 492 606 } 493 607 494 - 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") 495 610 user := rp.oauth.GetUser(r) 496 611 f, err := rp.repoResolver.Resolve(r) 497 612 if err != nil { 498 - log.Println("failed to get repo and knot", err) 613 + l.Error("failed to get repo and knot", "err", err) 614 + return 615 + } 616 + 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 499 621 return 500 622 } 501 623 502 - issueId := chi.URLParam(r, "issue") 503 - issueIdInt, err := strconv.Atoi(issueId) 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 504 629 if err != nil { 505 - http.Error(w, "bad issue id", http.StatusBadRequest) 506 - log.Println("failed to parse issue id", err) 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 507 632 return 508 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] 509 640 510 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 641 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 642 + LoggedInUser: user, 643 + RepoInfo: f.RepoInfo(user), 644 + Issue: issue, 645 + Comment: &comment, 646 + }) 647 + } 648 + 649 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 650 + l := rp.logger.With("handler", "DeleteIssueComment") 651 + user := rp.oauth.GetUser(r) 652 + f, err := rp.repoResolver.Resolve(r) 511 653 if err != nil { 512 - log.Println("failed to get issue", err) 513 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 654 + l.Error("failed to get repo and knot", "err", err) 514 655 return 515 656 } 516 657 517 - commentId := chi.URLParam(r, "comment_id") 518 - commentIdInt, err := strconv.Atoi(commentId) 519 - if err != nil { 520 - http.Error(w, "bad comment id", http.StatusBadRequest) 521 - log.Println("failed to parse issue id", err) 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 522 662 return 523 663 } 524 664 525 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 526 670 if err != nil { 527 - 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) 528 673 return 529 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] 530 681 531 - if comment.OwnerDid != user.Did { 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 532 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 533 685 return 534 686 } ··· 540 692 541 693 // optimistic deletion 542 694 deleted := time.Now() 543 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 544 696 if err != nil { 545 - log.Println("failed to delete comment") 697 + l.Error("failed to delete comment", "err", err) 546 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 547 699 return 548 700 } ··· 556 708 return 557 709 } 558 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 559 - Collection: tangled.GraphFollowNSID, 711 + Collection: tangled.RepoIssueCommentNSID, 560 712 Repo: user.Did, 561 713 Rkey: comment.Rkey, 562 714 }) ··· 570 722 comment.Deleted = &deleted 571 723 572 724 // htmx fragment of comment after deletion 573 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 574 726 LoggedInUser: user, 575 727 RepoInfo: f.RepoInfo(user), 576 728 Issue: issue, 577 - Comment: comment, 729 + Comment: &comment, 578 730 }) 579 731 } 580 732 ··· 604 756 return 605 757 } 606 758 607 - issues, err := db.GetIssuesPaginated(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 + ) 608 769 if err != nil { 609 770 log.Println("failed to get issues", err) 610 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 621 782 } 622 783 623 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 624 786 user := rp.oauth.GetUser(r) 625 787 626 788 f, err := rp.repoResolver.Resolve(r) 627 789 if err != nil { 628 - log.Println("failed to get repo and knot", err) 790 + l.Error("failed to get repo and knot", "err", err) 629 791 return 630 792 } 631 793 ··· 636 798 RepoInfo: f.RepoInfo(user), 637 799 }) 638 800 case http.MethodPost: 639 - title := r.FormValue("title") 640 - body := r.FormValue("body") 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(), 808 + } 641 809 642 - if title == "" || body == "" { 643 - rp.pages.Notice(w, "issues", "Title and body are required") 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)) 644 813 return 645 814 } 646 815 647 - sanitizer := markup.NewSanitizer() 648 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 649 - rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 816 + record := issue.AsRecord() 817 + 818 + // create an atproto record 819 + client, err := rp.oauth.AuthorizedClient(r) 820 + if err != nil { 821 + l.Error("failed to get authorized client", "err", err) 822 + rp.pages.Notice(w, "issues", "Failed to create issue.") 650 823 return 651 824 } 652 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 653 - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 825 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 826 + Collection: tangled.RepoIssueNSID, 827 + Repo: user.Did, 828 + Rkey: issue.Rkey, 829 + Record: &lexutil.LexiconTypeDecoder{ 830 + Val: &record, 831 + }, 832 + }) 833 + if err != nil { 834 + l.Error("failed to create issue", "err", err) 835 + rp.pages.Notice(w, "issues", "Failed to create issue.") 654 836 return 655 837 } 838 + atUri := resp.Uri 656 839 657 840 tx, err := rp.db.BeginTx(r.Context(), nil) 658 841 if err != nil { 659 842 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 660 843 return 661 844 } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 662 848 663 - issue := &db.Issue{ 664 - RepoAt: f.RepoAt(), 665 - Rkey: tid.TID(), 666 - Title: title, 667 - Body: body, 668 - OwnerDid: user.Did, 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 + } 669 856 } 670 - err = db.NewIssue(tx, issue) 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 671 860 if err != nil { 672 861 log.Println("failed to create issue", err) 673 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 674 863 return 675 864 } 676 865 677 - client, err := rp.oauth.AuthorizedClient(r) 678 - if err != nil { 679 - log.Println("failed to get authorized client", err) 680 - rp.pages.Notice(w, "issues", "Failed to create issue.") 681 - return 682 - } 683 - atUri := f.RepoAt().String() 684 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 685 - Collection: tangled.RepoIssueNSID, 686 - Repo: user.Did, 687 - Rkey: issue.Rkey, 688 - Record: &lexutil.LexiconTypeDecoder{ 689 - Val: &tangled.RepoIssue{ 690 - Repo: atUri, 691 - Title: title, 692 - Body: &body, 693 - Owner: user.Did, 694 - IssueId: int64(issue.IssueId), 695 - }, 696 - }, 697 - }) 698 - if err != nil { 866 + if err = tx.Commit(); err != nil { 699 867 log.Println("failed to create issue", err) 700 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 701 869 return 702 870 } 703 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 704 874 rp.notifier.NewIssue(r.Context(), issue) 705 - 706 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 707 876 return 708 877 } 709 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
+5 -34
appview/knots/knots.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 7 6 "log/slog" 8 7 "net/http" 9 8 "slices" ··· 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/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 21 "tangled.sh/tangled.sh/core/idresolver" 22 22 "tangled.sh/tangled.sh/core/rbac" ··· 49 49 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 - 53 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 54 52 55 53 return r 56 54 } ··· 399 397 if err != nil { 400 398 l.Error("verification failed", "err", err) 401 399 402 - if errors.Is(err, serververify.FetchError) { 403 - k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 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!") 404 402 return 405 403 } 406 404 ··· 420 418 return 421 419 } 422 420 423 - // if this knot was previously read-only, then emit a record too 421 + // if this knot requires upgrade, then emit a record too 424 422 // 425 423 // this is part of migrating from the old knot system to the new one 426 - if registration.ReadOnly { 424 + if registration.NeedsUpgrade { 427 425 // re-announce by registering under same rkey 428 426 client, err := k.OAuth.AuthorizedClient(r) 429 427 if err != nil { ··· 484 482 return 485 483 } 486 484 updatedRegistration := registrations[0] 487 - 488 - log.Println(updatedRegistration) 489 485 490 486 w.Header().Set("HX-Reswap", "outerHTML") 491 487 k.Pages.KnotListing(w, pages.KnotListingParams{ ··· 678 674 // ok 679 675 k.Pages.HxRefresh(w) 680 676 } 681 - 682 - func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 - user := k.OAuth.GetUser(r) 684 - l := k.Logger.With("handler", "removeMember") 685 - l = l.With("did", user.Did) 686 - l = l.With("handle", user.Handle) 687 - 688 - registrations, err := db.GetRegistrations( 689 - k.Db, 690 - db.FilterEq("did", user.Did), 691 - db.FilterEq("read_only", 1), 692 - ) 693 - if err != nil { 694 - l.Error("non-fatal: failed to get registrations") 695 - return 696 - } 697 - 698 - if registrations == nil { 699 - return 700 - } 701 - 702 - k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 - Registrations: registrations, 704 - }) 705 - }
+40
appview/middleware/middleware.go
··· 275 275 } 276 276 } 277 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 + 278 318 // this should serve the go-import meta tag even if the path is technically 279 319 // a 404 like tangled.sh/oppi.li/go-git/v5 280 320 func (mw Middleware) GoImport() middlewareFunc {
+15 -13
appview/oauth/handler/handler.go
··· 354 354 } 355 355 356 356 var ( 357 - tangledHandle = "tangled.sh" 358 - tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 - 360 - icyHandle = "icyphox.sh" 361 - icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 357 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 + icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 362 359 363 360 defaultSpindle = "spindle.tangled.sh" 364 361 defaultKnot = "knot1.tangled.sh" ··· 383 380 } 384 381 385 382 log.Printf("adding %s to default spindle", did) 386 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledHandle, tangledDid) 383 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 387 384 if err != nil { 388 385 log.Printf("failed to create session: %s", err) 389 386 return ··· 396 393 CreatedAt: time.Now().Format(time.RFC3339), 397 394 } 398 395 399 - if err := session.putRecord(record); err != nil { 396 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 400 397 log.Printf("failed to add member to default spindle: %s", err) 401 398 return 402 399 } ··· 420 417 } 421 418 422 419 log.Printf("adding %s to default knot", did) 423 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyHandle, icyDid) 420 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 424 421 if err != nil { 425 422 log.Printf("failed to create session: %s", err) 426 423 return ··· 433 430 CreatedAt: time.Now().Format(time.RFC3339), 434 431 } 435 432 436 - if err := session.putRecord(record); err != nil { 433 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 437 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) 438 440 return 439 441 } 440 442 ··· 448 450 Did string 449 451 } 450 452 451 - func (o *OAuthHandler) createAppPasswordSession(appPassword, handle, did string) (*session, error) { 453 + func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 452 454 if appPassword == "" { 453 455 return nil, fmt.Errorf("no app password configured, skipping member addition") 454 456 } ··· 464 466 } 465 467 466 468 sessionPayload := map[string]string{ 467 - "identifier": handle, 469 + "identifier": did, 468 470 "password": appPassword, 469 471 } 470 472 sessionBytes, err := json.Marshal(sessionPayload) ··· 501 503 return &session, nil 502 504 } 503 505 504 - func (s *session) putRecord(record any) error { 506 + func (s *session) putRecord(record any, collection string) error { 505 507 recordBytes, err := json.Marshal(record) 506 508 if err != nil { 507 509 return fmt.Errorf("failed to marshal knot member record: %w", err) ··· 509 511 510 512 payload := map[string]any{ 511 513 "repo": s.Did, 512 - "collection": tangled.KnotMemberNSID, 514 + "collection": collection, 513 515 "rkey": tid.TID(), 514 516 "record": json.RawMessage(recordBytes), 515 517 }
+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 + }
+3
appview/pages/funcmap.go
··· 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 32 35 "resolve": func(s string) string { 33 36 identity, err := p.resolver.ResolveIdent(context.Background(), s) 34 37
+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 {
+12 -8
appview/pages/markup/markdown.go
··· 11 11 12 12 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 13 "github.com/alecthomas/chroma/v2/styles" 14 + treeblood "github.com/wyatt915/goldmark-treeblood" 14 15 "github.com/yuin/goldmark" 15 16 highlighting "github.com/yuin/goldmark-highlighting/v2" 16 17 "github.com/yuin/goldmark/ast" ··· 21 22 "github.com/yuin/goldmark/util" 22 23 htmlparse "golang.org/x/net/html" 23 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 24 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 25 27 ) 26 28 ··· 59 61 extension.NewFootnote( 60 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 61 63 ), 64 + treeblood.MathML(), 62 65 ), 63 66 goldmark.WithParserOptions( 64 67 parser.WithAutoHeadingID(), ··· 229 232 230 233 actualPath := rctx.actualPath(dst) 231 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 + 232 240 parsedURL := &url.URL{ 233 - Scheme: scheme, 234 - Host: rctx.Knot, 235 - Path: path.Join("/", 236 - rctx.RepoInfo.OwnerDid, 237 - rctx.RepoInfo.Name, 238 - "raw", 239 - url.PathEscape(rctx.RepoInfo.Ref), 240 - actualPath), 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 241 245 } 242 246 newPath := parsedURL.String() 243 247 return newPath
+17
appview/pages/markup/sanitizer.go
··· 97 97 "margin-bottom", 98 98 ) 99 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 + 100 117 return policy 101 118 } 102 119
+270 -190
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 { ··· 64 65 65 66 p := &Pages{ 66 67 mu: sync.RWMutex{}, 67 - t: make(map[string]*template.Template), 68 + cache: NewTmplCache[string, *template.Template](), 68 69 dev: config.Core.Dev, 69 70 avatar: config.Avatar, 70 - embedFS: Files, 71 71 rctx: rctx, 72 72 resolver: res, 73 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 74 75 } 75 76 76 - // Initial load of all templates 77 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 78 82 79 83 return p 80 84 } 81 85 82 - func (p *Pages) loadAllTemplates() { 83 - templates := make(map[string]*template.Template) 84 - var fragmentPaths []string 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 85 89 86 - // Use embedded FS for initial loading 87 - // First, collect all fragment paths 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 96 + var fragmentPaths []string 88 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 89 98 if err != nil { 90 99 return err ··· 98 107 if !strings.Contains(path, "fragments/") { 99 108 return nil 100 109 } 101 - name := strings.TrimPrefix(path, "templates/") 102 - name = strings.TrimSuffix(name, ".html") 103 - tmpl, err := template.New(name). 104 - Funcs(p.funcMap()). 105 - ParseFS(p.embedFS, path) 106 - if err != nil { 107 - log.Fatalf("setting up fragment: %v", err) 108 - } 109 - templates[name] = tmpl 110 110 fragmentPaths = append(fragmentPaths, path) 111 - log.Printf("loaded fragment: %s", name) 112 111 return nil 113 112 }) 114 113 if err != nil { 115 - log.Fatalf("walking template dir for fragments: %v", err) 114 + return nil, err 116 115 } 117 116 118 - // Then walk through and setup the rest of the templates 119 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 120 - if err != nil { 121 - return err 122 - } 123 - if d.IsDir() { 124 - return nil 125 - } 126 - if !strings.HasSuffix(path, "html") { 127 - return nil 128 - } 129 - // Skip fragments as they've already been loaded 130 - if strings.Contains(path, "fragments/") { 131 - return nil 132 - } 133 - // Skip layouts 134 - if strings.Contains(path, "layouts/") { 135 - return nil 136 - } 137 - name := strings.TrimPrefix(path, "templates/") 138 - name = strings.TrimSuffix(name, ".html") 139 - // Add the page template on top of the base 140 - allPaths := []string{} 141 - allPaths = append(allPaths, "templates/layouts/*.html") 142 - allPaths = append(allPaths, fragmentPaths...) 143 - allPaths = append(allPaths, path) 144 - tmpl, err := template.New(name). 145 - Funcs(p.funcMap()). 146 - ParseFS(p.embedFS, allPaths...) 147 - if err != nil { 148 - return fmt.Errorf("setting up template: %w", err) 149 - } 150 - templates[name] = tmpl 151 - log.Printf("loaded template: %s", name) 152 - return nil 153 - }) 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() 154 123 if err != nil { 155 - log.Fatalf("walking template dir: %v", err) 124 + return nil, err 156 125 } 157 - 158 - log.Printf("total templates loaded: %d", len(templates)) 159 - p.mu.Lock() 160 - defer p.mu.Unlock() 161 - p.t = templates 162 - } 163 - 164 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 165 - func (p *Pages) loadTemplateFromDisk(name string) error { 166 - if !p.dev { 167 - return nil 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 168 128 } 169 129 170 - log.Printf("reloading template from disk: %s", name) 171 - 172 - // Find all fragments first 173 - var fragmentPaths []string 174 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 175 - if err != nil { 176 - return err 177 - } 178 - if d.IsDir() { 179 - return nil 180 - } 181 - if !strings.HasSuffix(path, ".html") { 182 - return nil 183 - } 184 - if !strings.Contains(path, "fragments/") { 185 - return nil 186 - } 187 - fragmentPaths = append(fragmentPaths, path) 188 - return nil 189 - }) 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 190 135 if err != nil { 191 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 136 + return nil, err 192 137 } 193 138 194 - // Find the template path on disk 195 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 196 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 197 - return fmt.Errorf("template not found on disk: %s", name) 198 - } 139 + return parsed, nil 140 + } 199 141 200 - // Create a new template 201 - tmpl := template.New(name).Funcs(p.funcMap()) 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 202 144 203 - // Parse layouts 204 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 205 - layouts, err := filepath.Glob(layoutGlob) 145 + // never cache in dev mode 146 + if cached, exists := p.cache.Get(key); !p.dev && exists { 147 + return cached, nil 148 + } 149 + 150 + result, err := p.rawParse(stack...) 206 151 if err != nil { 207 - return fmt.Errorf("finding layout templates: %w", err) 152 + return nil, err 208 153 } 209 154 210 - // Create paths for parsing 211 - allFiles := append(layouts, fragmentPaths...) 212 - allFiles = append(allFiles, templatePath) 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 213 158 214 - // Parse all templates 215 - tmpl, err = tmpl.ParseFiles(allFiles...) 216 - if err != nil { 217 - return fmt.Errorf("parsing template files: %w", err) 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 218 163 } 164 + return p.parse(stack...) 165 + } 219 166 220 - // Update the template in the map 221 - p.mu.Lock() 222 - defer p.mu.Unlock() 223 - p.t[name] = tmpl 224 - log.Printf("template reloaded from disk: %s", name) 225 - return nil 167 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 168 + stack := []string{ 169 + "layouts/base", 170 + "layouts/repobase", 171 + top, 172 + } 173 + return p.parse(stack...) 226 174 } 227 175 228 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 229 - // In dev mode, reload the template from disk before executing 230 - if p.dev { 231 - if err := p.loadTemplateFromDisk(templateName); err != nil { 232 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 233 - // Continue with the existing template 234 - } 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 235 181 } 182 + return p.parse(stack...) 183 + } 236 184 237 - p.mu.RLock() 238 - defer p.mu.RUnlock() 239 - tmpl, exists := p.t[templateName] 240 - if !exists { 241 - return fmt.Errorf("template not found: %s", templateName) 185 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 186 + tpl, err := p.parse(name) 187 + if err != nil { 188 + return err 242 189 } 243 190 244 - if base == "" { 245 - return tmpl.Execute(w, params) 246 - } else { 247 - return tmpl.ExecuteTemplate(w, base, params) 248 - } 191 + return tpl.Execute(w, params) 249 192 } 250 193 251 194 func (p *Pages) execute(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "layouts/base", params) 253 - } 195 + tpl, err := p.parseBase(name) 196 + if err != nil { 197 + return err 198 + } 254 199 255 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "", params) 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 257 201 } 258 202 259 203 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 260 - return p.executeOrReload(name, w, "layouts/repobase", params) 204 + tpl, err := p.parseRepoBase(name) 205 + if err != nil { 206 + return err 207 + } 208 + 209 + return tpl.ExecuteTemplate(w, "layouts/base", params) 210 + } 211 + 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) 261 219 } 262 220 263 221 func (p *Pages) Favicon(w io.Writer) error { ··· 282 240 283 241 type TermsOfServiceParams struct { 284 242 LoggedInUser *oauth.User 243 + Content template.HTML 285 244 } 286 245 287 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 + 288 259 return p.execute("legal/terms", w, params) 289 260 } 290 261 291 262 type PrivacyPolicyParams struct { 292 263 LoggedInUser *oauth.User 264 + Content template.HTML 293 265 } 294 266 295 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 + 296 280 return p.execute("legal/privacy", w, params) 297 281 } 298 282 ··· 338 322 return p.execute("user/settings/emails", w, params) 339 323 } 340 324 341 - type KnotBannerParams struct { 325 + type UpgradeBannerParams struct { 342 326 Registrations []db.Registration 327 + Spindles []db.Spindle 343 328 } 344 329 345 - func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 - return p.executePlain("knots/fragments/banner", w, params) 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 347 332 } 348 333 349 334 type KnotsParams struct { ··· 422 407 return p.execute("repo/fork", w, params) 423 408 } 424 409 425 - type ProfileHomePageParams 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 { 426 440 LoggedInUser *oauth.User 427 441 Repos []db.Repo 428 442 CollaboratingRepos []db.Repo 429 443 ProfileTimeline *db.ProfileTimeline 430 - Card ProfileCard 431 - Punchcard db.Punchcard 444 + Card *ProfileCard 445 + Active string 432 446 } 433 447 434 - type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount 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 + } 440 452 441 - Profile *db.Profile 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 442 458 } 443 459 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 - 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) 446 463 } 447 464 448 - type ReposPageParams struct { 465 + type ProfileStarredParams struct { 449 466 LoggedInUser *oauth.User 450 467 Repos []db.Repo 451 - Card ProfileCard 468 + Card *ProfileCard 469 + Active string 452 470 } 453 471 454 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 - 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) 456 487 } 457 488 458 489 type FollowCard struct { 459 490 UserDid string 460 491 FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 492 + FollowersCount int64 493 + FollowingCount int64 463 494 Profile *db.Profile 464 495 } 465 496 466 - type FollowersPageParams struct { 497 + type ProfileFollowersParams struct { 467 498 LoggedInUser *oauth.User 468 499 Followers []FollowCard 469 - Card ProfileCard 500 + Card *ProfileCard 501 + Active string 470 502 } 471 503 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 474 507 } 475 508 476 - type FollowingPageParams struct { 509 + type ProfileFollowingParams struct { 477 510 LoggedInUser *oauth.User 478 511 Following []FollowCard 479 - Card ProfileCard 512 + Card *ProfileCard 513 + Active string 480 514 } 481 515 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 484 519 } 485 520 486 521 type FollowFragmentParams struct { ··· 553 588 VerifiedCommits commitverify.VerifiedCommits 554 589 Languages []types.RepoLanguageDetails 555 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 556 592 types.RepoIndexResponse 557 593 } 558 594 ··· 562 598 return p.executeRepo("repo/empty", w, params) 563 599 } 564 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 565 605 p.rctx.RepoInfo = params.RepoInfo 566 606 p.rctx.RepoInfo.Ref = params.Ref 567 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 649 689 650 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 651 691 params.Active = "overview" 652 - return p.execute("repo/tree", w, params) 692 + return p.executeRepo("repo/tree", w, params) 653 693 } 654 694 655 695 type RepoBranchesParams struct { ··· 700 740 ShowRendered bool 701 741 RenderToggle bool 702 742 RenderedContents template.HTML 703 - types.RepoBlobResponse 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 704 749 } 705 750 706 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 835 880 RepoInfo repoinfo.RepoInfo 836 881 Active string 837 882 Issue *db.Issue 838 - Comments []db.Comment 883 + CommentList []db.CommentListItem 839 884 IssueOwnerHandle string 840 885 841 886 OrderedReactionKinds []db.ReactionKind 842 887 Reactions map[db.ReactionKind]int 843 888 UserReacted map[db.ReactionKind]bool 889 + } 844 890 845 - State string 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 + } 895 + 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) 846 906 } 847 907 848 908 type ThreadReactionFragmentParams struct { ··· 856 916 return p.executePlain("repo/fragments/reaction", w, params) 857 917 } 858 918 859 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 860 - params.Active = "issues" 861 - if params.Issue.Open { 862 - params.State = "open" 863 - } else { 864 - params.State = "closed" 865 - } 866 - return p.execute("repo/issues/issue", w, params) 867 - } 868 - 869 919 type RepoNewIssueParams struct { 870 920 LoggedInUser *oauth.User 871 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 872 923 Active string 924 + Action string 873 925 } 874 926 875 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 876 928 params.Active = "issues" 929 + params.Action = "create" 877 930 return p.executeRepo("repo/issues/new", w, params) 878 931 } 879 932 ··· 881 934 LoggedInUser *oauth.User 882 935 RepoInfo repoinfo.RepoInfo 883 936 Issue *db.Issue 884 - Comment *db.Comment 937 + Comment *db.IssueComment 885 938 } 886 939 887 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 888 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 889 942 } 890 943 891 - type SingleIssueCommentParams struct { 944 + type ReplyIssueCommentPlaceholderParams struct { 892 945 LoggedInUser *oauth.User 893 946 RepoInfo repoinfo.RepoInfo 894 947 Issue *db.Issue 895 - Comment *db.Comment 948 + Comment *db.IssueComment 896 949 } 897 950 898 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 899 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 952 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 953 + } 954 + 955 + type ReplyIssueCommentParams struct { 956 + LoggedInUser *oauth.User 957 + RepoInfo repoinfo.RepoInfo 958 + Issue *db.Issue 959 + Comment *db.IssueComment 960 + } 961 + 962 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 963 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 964 + } 965 + 966 + type IssueCommentBodyParams struct { 967 + LoggedInUser *oauth.User 968 + RepoInfo repoinfo.RepoInfo 969 + Issue *db.Issue 970 + Comment *db.IssueComment 971 + } 972 + 973 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 974 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 900 975 } 901 976 902 977 type RepoNewPullParams struct { ··· 1262 1337 return p.execute("strings/string", w, params) 1263 1338 } 1264 1339 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1265 1344 func (p *Pages) Static() http.Handler { 1266 1345 if p.dev { 1267 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1269 1348 1270 1349 sub, err := fs.Sub(Files, "static") 1271 1350 if err != nil { 1272 - 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) 1273 1353 } 1274 1354 // Custom handler to apply Cache-Control headers for font files 1275 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1292 1372 func CssContentHash() string { 1293 1373 cssFile, err := Files.Open("static/tw.css") 1294 1374 if err != nil { 1295 - log.Printf("Error opening CSS file: %v", err) 1375 + slog.Debug("Error opening CSS file", "err", err) 1296 1376 return "" 1297 1377 } 1298 1378 defer cssFile.Close() 1299 1379 1300 1380 hasher := sha256.New() 1301 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1302 - log.Printf("Error hashing CSS file: %v", err) 1382 + slog.Debug("Error hashing CSS file", "err", err) 1303 1383 return "" 1304 1384 } 1305 1385
+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 }}
+1 -1
appview/pages/templates/errors/404.html
··· 17 17 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 20 + <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 go back 23 23 </a>
+4 -4
appview/pages/templates/errors/500.html
··· 8 8 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 - 11 + 12 12 <div class="space-y-4"> 13 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 14 500 &mdash; internal server error ··· 24 24 <p class="mt-1">Our team has been automatically notified about this error.</p> 25 25 </div> 26 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 27 + <button onclick="location.reload()" class="btn-create gap-2"> 28 28 {{ i "refresh-cw" "w-4 h-4" }} 29 29 try again 30 30 </button> 31 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 32 {{ i "home" "w-4 h-4" }} 33 33 back to home 34 34 </a> ··· 36 36 </div> 37 37 </div> 38 38 </div> 39 - {{ end }} 39 + {{ end }}
+2 -2
appview/pages/templates/errors/503.html
··· 17 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 21 {{ i "refresh-cw" "w-4 h-4" }} 22 22 try again 23 23 </button> 24 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 25 {{ i "arrow-left" "w-4 h-4" }} 26 26 back to timeline 27 27 </a>
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 back to timeline 23 23 </a>
+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 }}
-9
appview/pages/templates/knots/fragments/banner.html
··· 1 - {{ define "knots/fragments/banner" }} 2 - <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 - A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 - that you administer is presently read-only. Consider upgrading this knot to 5 - continue creating repositories on it. 6 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 - </div> 8 - {{ end }} 9 -
+2 -2
appview/pages/templates/knots/fragments/knotListing.html
··· 36 36 </span> 37 37 {{ template "knots/fragments/addMemberModal" . }} 38 38 {{ block "knotDeleteButton" . }} {{ end }} 39 - {{ else if .IsReadOnly }} 39 + {{ else if .IsNeedsUpgrade }} 40 40 <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 - {{ i "shield-alert" "w-4 h-4" }} read-only 41 + {{ i "shield-alert" "w-4 h-4" }} needs upgrade 42 42 </span> 43 43 {{ block "knotRetryButton" . }} {{ end }} 44 44 {{ block "knotDeleteButton" . }} {{ end }}
+12 -10
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"> ··· 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 - </section> 27 + 28 + 29 + </section> 28 30 {{ end }} 29 31 30 32 {{ define "list" }}
+27 -12
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 42 <main class="col-span-1 md:col-span-8"> 28 43 {{ block "content" . }}{{ end }} ··· 38 53 {{ end }} 39 54 40 55 {{ block "footerLayout" . }} 41 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 - {{ 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" . }} 43 58 </footer> 44 59 {{ end }} 45 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 }}
+104
appview/pages/templates/layouts/profilebase.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + <div class="md:col-span-3 order-1 md:order-1"> 15 + <div class="flex flex-col gap-4"> 16 + {{ template "user/fragments/profileCard" .Card }} 17 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 18 + </div> 19 + </div> 20 + {{ block "profileContent" . }} {{ end }} 21 + </div> 22 + </section> 23 + {{ end }} 24 + 25 + {{ define "profileTabs" }} 26 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 27 + <div class="flex z-60"> 28 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 29 + {{ $tabs := .Card.GetTabs }} 30 + {{ $tabmeta := dict "x" "y" }} 31 + {{ range $item := $tabs }} 32 + {{ $key := index $item 0 }} 33 + {{ $value := index $item 1 }} 34 + {{ $icon := index $item 2 }} 35 + {{ $meta := index $item 3 }} 36 + <a 37 + href="?tab={{ $value }}" 38 + class="relative -mr-px group no-underline hover:no-underline" 39 + hx-boost="true"> 40 + <div 41 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 42 + {{ if eq $.Active $key }} 43 + {{ $activeTabStyles }} 44 + {{ else }} 45 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 46 + {{ end }} 47 + "> 48 + <span class="flex items-center justify-center"> 49 + {{ i $icon "w-4 h-4 mr-2" }} 50 + {{ $key }} 51 + {{ if $meta }} 52 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 53 + {{ end }} 54 + </span> 55 + </div> 56 + </a> 57 + {{ end }} 58 + </div> 59 + </nav> 60 + {{ end }} 61 + 62 + {{ define "punchcard" }} 63 + {{ $now := now }} 64 + <div> 65 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 66 + PUNCHCARD 67 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 68 + {{ .Total | int64 | commaFmt }} commits 69 + </span> 70 + </p> 71 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 72 + {{ range .Punches }} 73 + {{ $count := .Count }} 74 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 75 + {{ if lt $count 1 }} 76 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 77 + {{ else if lt $count 2 }} 78 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 79 + {{ else if lt $count 4 }} 80 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 81 + {{ else if lt $count 8 }} 82 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 83 + {{ else }} 84 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 85 + {{ end }} 86 + 87 + {{ if .Date.After $now }} 88 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 89 + {{ end }} 90 + <div class="w-full h-full flex justify-center items-center"> 91 + <div 92 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 93 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 94 + </div> 95 + </div> 96 + {{ end }} 97 + </div> 98 + </div> 99 + {{ end }} 100 + 101 + {{ define "layouts/profilebase" }} 102 + {{ template "layouts/base" . }} 103 + {{ end }} 104 +
+4 -8
appview/pages/templates/layouts/repobase.html
··· 42 42 </section> 43 43 44 44 <section 45 - class="w-full flex flex-col drop-shadow-sm" 45 + class="w-full flex flex-col" 46 46 > 47 47 <nav class="w-full pl-4 overflow-auto"> 48 48 <div class="flex z-60"> ··· 71 71 <span class="flex items-center justify-center"> 72 72 {{ i $icon "w-4 h-4 mr-2" }} 73 73 {{ $key }} 74 - {{ if not (isNil $meta) }} 75 - <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> 76 76 {{ end }} 77 77 </span> 78 78 </div> ··· 81 81 </div> 82 82 </nav> 83 83 <section 84 - 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" 85 85 > 86 86 {{ block "repoContent" . }}{{ end }} 87 87 </section> 88 88 {{ block "repoAfter" . }}{{ end }} 89 89 </section> 90 90 {{ end }} 91 - 92 - {{ define "layouts/repobase" }} 93 - {{ template "layouts/base" . }} 94 - {{ end }}
-87
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-bold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - 10 - <div id="right-items" class="flex items-center gap-2"> 11 - {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ block "dropDown" . }} {{ end }} 14 - {{ else }} 15 - <a href="/login">login</a> 16 - <span class="text-gray-500 dark:text-gray-400">or</span> 17 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 - join now {{ i "arrow-right" "size-4" }} 19 - </a> 20 - {{ end }} 21 - </div> 22 - </div> 23 - </nav> 24 - {{ if .LoggedInUser }} 25 - <div id="upgrade-banner" 26 - hx-get="/knots/upgradeBanner" 27 - hx-trigger="load" 28 - hx-swap="innerHTML"> 29 - </div> 30 - {{ end }} 31 - {{ end }} 32 - 33 - {{ define "newButton" }} 34 - <details class="relative inline-block text-left nav-dropdown"> 35 - <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 - {{ i "plus" "w-4 h-4" }} new 37 - </summary> 38 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 - <a href="/repo/new" class="flex items-center gap-2"> 40 - {{ i "book-plus" "w-4 h-4" }} 41 - new repository 42 - </a> 43 - <a href="/strings/new" class="flex items-center gap-2"> 44 - {{ i "line-squiggle" "w-4 h-4" }} 45 - new string 46 - </a> 47 - </div> 48 - </details> 49 - {{ end }} 50 - 51 - {{ define "dropDown" }} 52 - <details class="relative inline-block text-left nav-dropdown"> 53 - <summary 54 - class="cursor-pointer list-none flex items-center" 55 - > 56 - {{ $user := didOrHandle .Did .Handle }} 57 - {{ template "user/fragments/picHandle" $user }} 58 - </summary> 59 - <div 60 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 61 - > 62 - <a href="/{{ $user }}">profile</a> 63 - <a href="/{{ $user }}?tab=repos">repositories</a> 64 - <a href="/strings/{{ $user }}">strings</a> 65 - <a href="/knots">knots</a> 66 - <a href="/spindles">spindles</a> 67 - <a href="/settings">settings</a> 68 - <a href="#" 69 - hx-post="/logout" 70 - hx-swap="none" 71 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 72 - logout 73 - </a> 74 - </div> 75 - </details> 76 - 77 - <script> 78 - document.addEventListener('click', function(event) { 79 - const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 - dropdowns.forEach(function(dropdown) { 81 - if (!dropdown.contains(event.target)) { 82 - dropdown.removeAttribute('open'); 83 - } 84 - }); 85 - }); 86 - </script> 87 - {{ end }}
+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 }}
+2 -2
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
+2 -2
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
+1 -1
appview/pages/templates/repo/fork.html
··· 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>
+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>
+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 }}
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 + {{ end }} 4 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 1 {{ define "repo/fragments/time" }} 6 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 3 {{ end }} 8 - 9 - {{ define "repo/fragments/shortTime" }} 10 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 - {{ end }} 12 - 13 - {{ define "repo/fragments/shortTimeAgo" }} 14 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 - {{ end }} 16 - 17 - {{ define "repo/fragments/duration" }} 18 - <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 - {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+25 -8
appview/pages/templates/repo/index.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <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"> 40 + {{ range $value := .Languages }} 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"> 39 48 {{ range $value := .Languages }} 40 - <div 41 - title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 42 - class="h-[4px] rounded-full" 43 - style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 - ></div> 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> 45 62 {{ end }} 46 - </div> 63 + </div> 64 + </details> 47 65 {{ end }} 48 - 49 66 50 67 {{ define "branchSelector" }} 51 68 <div class="flex gap-2 items-center justify-between w-full">
+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 | description }} 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.AtUri) 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 | 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 }} 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 }}
+1 -1
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>
+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>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 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>
+1 -1
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 }}
+2 -2
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
+2 -2
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
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 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>
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 30 30 {{ define "spindleRightSide" }} 31 31 <div id="right-side" class="flex gap-2"> 32 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ 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 }} 34 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> 35 39 {{ template "spindles/fragments/addMemberModal" . }} 36 40 {{ else }} 37 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> 38 42 {{ block "spindleRetryButton" . }} {{ end }} 39 43 {{ end }} 44 + 40 45 {{ block "spindleDeleteButton" . }} {{ end }} 41 46 </div> 42 47 {{ end }}
+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" }}
-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" }}
-4
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">
-4
appview/pages/templates/strings/timeline.html
··· 1 1 {{ define "title" }} all strings {{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 {{ block "timeline" $ }}{{ end }} 9 5 {{ end }}
+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 +
+6 -171
appview/pages/templates/timeline/timeline.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 - {{ if .LoggedInUser }} 12 - {{ else }} 13 - {{ block "hero" $ }}{{ end }} 14 - {{ end }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 15 16 - {{ block "trending" $ }}{{ end }} 17 - {{ block "timeline" $ }}{{ end }} 18 - {{ end }} 19 - 20 - {{ define "hero" }} 21 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 - 24 - <p class="text-lg"> 25 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 - </p> 27 - <p class="text-lg"> 28 - we envision a place where developers have complete ownership of their 29 - code, open source communities can freely self-govern and most 30 - importantly, coding can be social and fun again. 31 - </p> 32 - 33 - <div class="flex gap-6 items-center"> 34 - <a href="/signup" class="no-underline hover:no-underline "> 35 - <button class="btn-create flex gap-2 px-4 items-center"> 36 - join now {{ i "arrow-right" "size-4" }} 37 - </button> 38 - </a> 39 - </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ define "trending" }} 44 - <div class="w-full md:mx-0 py-4"> 45 - <div class="px-6 pb-4"> 46 - <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 - Trending 48 - {{ i "trending-up" "size-4 flex-shrink-0" }} 49 - </h3> 50 - </div> 51 - <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 - {{ range $index, $repo := .Repos }} 53 - <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 - {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 - </div> 56 - {{ else }} 57 - <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 - <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 - No trending repositories this week 60 - </div> 61 - </div> 62 - {{ end }} 63 - </div> 64 - </div> 65 - {{ end }} 66 - 67 - {{ define "timeline" }} 68 - <div class="py-4"> 69 - <div class="px-6 pb-4"> 70 - <p class="text-xl font-bold dark:text-white">Timeline</p> 71 - </div> 72 - 73 - <div class="flex flex-col gap-4"> 74 - {{ range $i, $e := .Timeline }} 75 - <div class="relative"> 76 - {{ if ne $i 0 }} 77 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 - {{ end }} 79 - {{ with $e }} 80 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 - {{ if .Repo }} 82 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 - {{ else if .Star }} 84 - {{ block "starEvent" (list $ .Star) }} {{ end }} 85 - {{ else if .Follow }} 86 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 - {{ end }} 88 - </div> 89 - {{ end }} 90 - </div> 91 - {{ end }} 92 - </div> 93 - </div> 94 - {{ end }} 95 - 96 - {{ define "repoEvent" }} 97 - {{ $root := index . 0 }} 98 - {{ $repo := index . 1 }} 99 - {{ $source := index . 2 }} 100 - {{ $userHandle := resolve $repo.Did }} 101 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 - {{ template "user/fragments/picHandleLink" $repo.Did }} 103 - {{ with $source }} 104 - {{ $sourceDid := resolve .Did }} 105 - forked 106 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 - {{ $sourceDid }}/{{ .Name }} 108 - </a> 109 - to 110 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 - {{ else }} 112 - created 113 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 - {{ $repo.Name }} 115 - </a> 116 - {{ end }} 117 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 - </div> 119 - {{ with $repo }} 120 - {{ template "user/fragments/repoCard" (list $root . true) }} 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "starEvent" }} 125 - {{ $root := index . 0 }} 126 - {{ $star := index . 1 }} 127 - {{ with $star }} 128 - {{ $starrerHandle := resolve .StarredByDid }} 129 - {{ $repoOwnerHandle := resolve .Repo.Did }} 130 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 - starred 133 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 - </a> 136 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 - </div> 138 - {{ with .Repo }} 139 - {{ template "user/fragments/repoCard" (list $root . true) }} 140 - {{ end }} 141 - {{ end }} 142 - {{ end }} 143 - 144 - 145 - {{ define "followEvent" }} 146 - {{ $root := index . 0 }} 147 - {{ $follow := index . 1 }} 148 - {{ $profile := index . 2 }} 149 - {{ $stat := index . 3 }} 150 - 151 - {{ $userHandle := resolve $follow.UserDid }} 152 - {{ $subjectHandle := resolve $follow.SubjectDid }} 153 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 - {{ template "user/fragments/picHandleLink" $userHandle }} 155 - followed 156 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 - </div> 159 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 - </div> 163 - 164 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 - <a href="/{{ $subjectHandle }}"> 166 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 - </a> 168 - {{ with $profile }} 169 - {{ with .Description }} 170 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 - {{ end }} 172 - {{ end }} 173 - {{ with $stat }} 174 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 - <span class="select-none after:content-['ยท']"></span> 178 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 179 - </div> 180 - {{ end }} 181 - </div> 182 - </div> 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 183 18 {{ 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.
+4 -16
appview/pages/templates/user/followers.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 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-followers" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "followers" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "followers" }}
+4 -16
appview/pages/templates/user/following.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 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-following" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "following" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "following" }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/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 }}
+2 -4
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 2 {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 4 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 6 5 <div class="w-3/4 aspect-square relative"> ··· 85 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 86 85 </div> 87 86 </div> 88 - </div> 89 87 {{ end }} 90 88 91 89 {{ define "followerFollowing" }} ··· 94 92 {{ with $root }} 95 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 98 96 <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 100 98 </div> 101 99 {{ end }} 102 100 {{ end }}
+1 -2
appview/pages/templates/user/fragments/repoCard.html
··· 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 }}
+2 -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.
+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 }}?tab=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 }}
+2 -2
appview/pages/templates/user/settings/emails.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 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 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+2 -2
appview/pages/templates/user/settings/keys.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 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 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+2 -2
appview/pages/templates/user/settings/profile.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 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 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+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 }}
+1 -1
appview/posthog/notifier.go
··· 58 58 59 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 60 err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 61 + DistinctId: issue.Did, 62 62 Event: "new_issue", 63 63 Properties: posthog.Properties{ 64 64 "repo_at": issue.RepoAt.String(),
+252 -104
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 7 8 "log" ··· 21 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 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" ··· 99 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{ ··· 154 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) ··· 282 282 return result 283 283 } 284 284 285 - 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 { 286 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 287 return pages.Unknown 288 288 } ··· 307 307 repoName = f.Name 308 308 } 309 309 310 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 311 - if err != nil { 312 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 313 - 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, 314 317 } 315 318 316 - 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) 317 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 + } 318 326 log.Println("failed to reach knotserver", err) 319 327 return pages.Unknown 320 328 } 321 329 330 + targetBranch := branchResp 331 + 322 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 323 333 324 334 if pull.IsStacked() && stack != nil { ··· 326 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 327 337 } 328 338 329 - if latestSourceRev != result.Branch.Hash { 339 + if latestSourceRev != targetBranch.Hash { 330 340 return pages.ShouldResubmit 331 341 } 332 342 ··· 605 615 defer tx.Rollback() 606 616 607 617 createdAt := time.Now().Format(time.RFC3339) 608 - ownerDid := user.Did 609 618 610 619 pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 611 620 if err != nil { ··· 614 623 return 615 624 } 616 625 617 - atUri := f.RepoAt().String() 618 626 client, err := s.oauth.AuthorizedClient(r) 619 627 if err != nil { 620 628 log.Println("failed to get authorized client", err) ··· 627 635 Rkey: tid.TID(), 628 636 Record: &lexutil.LexiconTypeDecoder{ 629 637 Val: &tangled.RepoPullComment{ 630 - Repo: &atUri, 631 638 Pull: string(pullAt), 632 - Owner: &ownerDid, 633 639 Body: body, 634 640 CreatedAt: createdAt, 635 641 }, ··· 682 688 683 689 switch r.Method { 684 690 case http.MethodGet: 685 - 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) 686 702 if err != nil { 687 - log.Printf("failed to create unsigned client for %s", f.Knot) 688 - 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) 689 709 return 690 710 } 691 711 692 - result, err := us.Branches(f.OwnerDid(), f.Name) 693 - if err != nil { 694 - 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) 695 716 return 696 717 } 697 718 ··· 756 777 return 757 778 } 758 779 759 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 760 - if err != nil { 761 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 762 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 763 - return 764 - } 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 + // } 765 786 766 - caps, err := us.Capabilities() 767 - if err != nil { 768 - log.Println("error fetching knot caps", f.Knot, err) 769 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 770 - return 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 + }, 771 807 } 808 + 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 + // } 772 815 773 816 if !caps.PullRequests.FormatPatch { 774 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 810 853 sourceBranch string, 811 854 isStacked bool, 812 855 ) { 813 - // Generate a patch using /compare 814 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 815 - if err != nil { 816 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 817 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 818 - 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, 819 863 } 820 864 821 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 822 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 + } 823 873 log.Println("failed to compare", err) 824 874 s.pages.Notice(w, "pull", err.Error()) 875 + return 876 + } 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.") 825 882 return 826 883 } 827 884 ··· 854 911 } 855 912 856 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) { 857 - 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) 858 918 if errors.Is(err, sql.ErrNoRows) { 859 919 s.pages.Notice(w, "pull", "No such fork.") 860 920 return ··· 870 930 oauth.WithLxm(tangled.RepoHiddenRefNSID), 871 931 oauth.WithDev(s.config.Core.Dev), 872 932 ) 873 - if err != nil { 874 - log.Printf("failed to connect to knot server: %v", 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 - } 885 933 886 934 resp, err := tangled.RepoHiddenRef( 887 935 r.Context(), ··· 912 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 913 961 // targetBranch: main (on repo-1) 914 962 // sourceBranch: feature-1 (on repo-fork) 915 - 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) 916 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 + } 917 980 log.Println("failed to compare across branches", err) 918 981 s.pages.Notice(w, "pull", err.Error()) 982 + return 983 + } 984 + 985 + var comparison types.RepoFormatPatchResponse 986 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 987 + log.Println("failed to decode XRPC compare response for fork", err) 988 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 919 989 return 920 990 } 921 991 ··· 1038 1108 Rkey: rkey, 1039 1109 Record: &lexutil.LexiconTypeDecoder{ 1040 1110 Val: &tangled.RepoPull{ 1041 - Title: title, 1042 - PullId: int64(pullId), 1043 - TargetRepo: string(f.RepoAt()), 1044 - TargetBranch: targetBranch, 1045 - Patch: patch, 1046 - 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, 1047 1118 }, 1048 1119 }, 1049 1120 }) ··· 1211 1282 return 1212 1283 } 1213 1284 1214 - 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) 1215 1296 if err != nil { 1216 - log.Printf("failed to create unsigned client for %s", f.Knot) 1217 - 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) 1218 1303 return 1219 1304 } 1220 1305 1221 - result, err := us.Branches(f.OwnerDid(), f.Name) 1222 - if err != nil { 1223 - 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) 1224 1310 return 1225 1311 } 1226 1312 ··· 1274 1360 } 1275 1361 1276 1362 forkVal := r.URL.Query().Get("fork") 1277 - 1363 + repoString := strings.SplitN(forkVal, "/", 2) 1364 + forkOwnerDid := repoString[0] 1365 + forkName := repoString[1] 1278 1366 // fork repo 1279 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1367 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1280 1368 if err != nil { 1281 1369 log.Println("failed to get repo", user.Did, forkVal) 1282 1370 return 1283 1371 } 1284 1372 1285 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1286 - if err != nil { 1287 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1288 - s.pages.Error503(w) 1289 - 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, 1290 1380 } 1291 1381 1292 - 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) 1293 1384 if err != nil { 1294 - 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) 1295 1391 return 1296 1392 } 1297 1393 1298 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1299 - if err != nil { 1300 - 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) 1301 1398 s.pages.Error503(w) 1302 1399 return 1303 1400 } 1304 1401 1305 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 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) 1306 1413 if err != nil { 1307 - 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) 1308 1420 return 1309 1421 } 1310 1422 1311 - sourceBranches := sourceResult.Branches 1312 - sort.Slice(sourceBranches, func(i int, j int) bool { 1313 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1423 + // Decode target branches 1424 + var targetBranches types.RepoBranchesResponse 1425 + if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1426 + log.Println("failed to decode target branches XRPC response", err) 1427 + s.pages.Error503(w) 1428 + return 1429 + } 1430 + 1431 + sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1432 + return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1314 1433 }) 1315 1434 1316 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1317 1436 RepoInfo: f.RepoInfo(user), 1318 - SourceBranches: sourceBranches, 1319 - TargetBranches: targetResult.Branches, 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1320 1439 }) 1321 1440 } 1322 1441 ··· 1411 1530 return 1412 1531 } 1413 1532 1414 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1415 - if err != nil { 1416 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1417 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1418 - 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, 1419 1540 } 1420 1541 1421 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, 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) 1422 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 + } 1423 1550 log.Printf("compare request failed: %s", err) 1424 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.") 1425 1559 return 1426 1560 } 1427 1561 ··· 1461 1595 } 1462 1596 1463 1597 // extract patch by performing compare 1464 - 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) 1465 1605 if err != nil { 1466 - 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) 1612 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1613 + return 1614 + } 1615 + 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) 1467 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1468 1620 return 1469 1621 } ··· 1499 1651 return 1500 1652 } 1501 1653 1502 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1503 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1504 - if err != nil { 1505 - log.Printf("failed to compare branches: %s", err) 1506 - s.pages.Notice(w, "resubmit-error", err.Error()) 1507 - return 1508 - } 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1509 1656 1510 1657 sourceRev := comparison.Rev2 1511 1658 patch := comparison.Patch ··· 1609 1756 SwapRecord: ex.Cid, 1610 1757 Record: &lexutil.LexiconTypeDecoder{ 1611 1758 Val: &tangled.RepoPull{ 1612 - Title: pull.Title, 1613 - PullId: int64(pull.PullId), 1614 - TargetRepo: string(f.RepoAt()), 1615 - TargetBranch: pull.TargetBranch, 1616 - Patch: patch, // new patch 1617 - 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, 1618 1766 }, 1619 1767 }, 1620 1768 })
+26 -8
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") ··· 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") ··· 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.Name) 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
+7 -2
appview/repo/feed.go
··· 9 9 "time" 10 10 11 11 "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/pagination" 12 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 13 14 14 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 23 24 return nil, err 24 25 } 25 26 26 - issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 27 32 if err != nil { 28 33 return nil, err 29 34 } ··· 104 109 } 105 110 106 111 func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 - owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 108 113 if err != nil { 109 114 return nil, err 110 115 }
+207 -22
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "errors" 5 + "fmt" 4 6 "log" 5 7 "net/http" 6 8 "slices" 7 9 "sort" 8 10 "strings" 11 + "sync" 12 + "time" 9 13 14 + "context" 15 + "encoding/json" 16 + 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 + "github.com/go-git/go-git/v5/plumbing" 19 + "tangled.sh/tangled.sh/core/api/tangled" 10 20 "tangled.sh/tangled.sh/core/appview/commitverify" 11 21 "tangled.sh/tangled.sh/core/appview/db" 12 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/appview/pages/markup" 13 24 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 - "tangled.sh/tangled.sh/core/knotclient" 25 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 15 26 "tangled.sh/tangled.sh/core/types" 16 27 17 28 "github.com/go-chi/chi/v5" ··· 27 38 return 28 39 } 29 40 30 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 31 - if err != nil { 32 - log.Printf("failed to create unsigned client for %s", f.Knot) 33 - rp.pages.Error503(w) 34 - return 41 + scheme := "http" 42 + if !rp.config.Core.Dev { 43 + scheme = "https" 44 + } 45 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 46 + xrpcc := &indigoxrpc.Client{ 47 + Host: host, 35 48 } 36 49 37 - result, err := us.Index(f.OwnerDid(), f.Name, ref) 38 - if err != nil { 39 - rp.pages.Error503(w) 40 - log.Println("failed to reach knotserver", err) 41 - return 50 + user := rp.oauth.GetUser(r) 51 + repoInfo := f.RepoInfo(user) 52 + 53 + // Build index response from multiple XRPC calls 54 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 55 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 56 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 57 + log.Println("failed to call XRPC repo.index", err) 58 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 59 + LoggedInUser: user, 60 + NeedsKnotUpgrade: true, 61 + RepoInfo: repoInfo, 62 + }) 63 + return 64 + } else { 65 + rp.pages.Error503(w) 66 + log.Println("failed to build index response", err) 67 + return 68 + } 42 69 } 43 70 44 71 tagMap := make(map[string][]string) ··· 98 125 log.Println(err) 99 126 } 100 127 101 - user := rp.oauth.GetUser(r) 102 - repoInfo := f.RepoInfo(user) 103 - 104 128 // TODO: a bit dirty 105 - languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 129 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 106 130 if err != nil { 107 131 log.Printf("failed to compute language percentages: %s", err) 108 132 // non-fatal ··· 135 159 } 136 160 137 161 func (rp *Repo) getLanguageInfo( 162 + ctx context.Context, 138 163 f *reporesolver.ResolvedRepo, 139 - us *knotclient.UnsignedClient, 164 + xrpcc *indigoxrpc.Client, 140 165 currentRef string, 141 166 isDefaultRef bool, 142 167 ) ([]types.RepoLanguageDetails, error) { ··· 148 173 ) 149 174 150 175 if err != nil || langs == nil { 151 - // non-fatal, fetch langs from ks 152 - ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 176 + // non-fatal, fetch langs from ks via XRPC 177 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 178 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 153 179 if err != nil { 180 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 181 + log.Println("failed to call XRPC repo.languages", xrpcerr) 182 + return nil, xrpcerr 183 + } 154 184 return nil, err 155 185 } 156 - if ls == nil { 186 + 187 + if ls == nil || ls.Languages == nil { 157 188 return nil, nil 158 189 } 159 190 160 - for l, s := range ls.Languages { 191 + for _, lang := range ls.Languages { 161 192 langs = append(langs, db.RepoLanguage{ 162 193 RepoAt: f.RepoAt(), 163 194 Ref: currentRef, 164 195 IsDefaultRef: isDefaultRef, 165 - Language: l, 166 - Bytes: s, 196 + Language: lang.Name, 197 + Bytes: lang.Size, 167 198 }) 168 199 } 169 200 ··· 206 237 207 238 return languageStats, nil 208 239 } 240 + 241 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 242 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 243 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 244 + 245 + // first get branches to determine the ref if not specified 246 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 247 + if err != nil { 248 + return nil, err 249 + } 250 + 251 + var branchesResp types.RepoBranchesResponse 252 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 253 + return nil, err 254 + } 255 + 256 + // if no ref specified, use default branch or first available 257 + if ref == "" && len(branchesResp.Branches) > 0 { 258 + for _, branch := range branchesResp.Branches { 259 + if branch.IsDefault { 260 + ref = branch.Name 261 + break 262 + } 263 + } 264 + if ref == "" { 265 + ref = branchesResp.Branches[0].Name 266 + } 267 + } 268 + 269 + // check if repo is empty 270 + if len(branchesResp.Branches) == 0 { 271 + return &types.RepoIndexResponse{ 272 + IsEmpty: true, 273 + Branches: branchesResp.Branches, 274 + }, nil 275 + } 276 + 277 + // now run the remaining queries in parallel 278 + var wg sync.WaitGroup 279 + var errs error 280 + 281 + var ( 282 + tagsResp types.RepoTagsResponse 283 + treeResp *tangled.RepoTree_Output 284 + logResp types.RepoLogResponse 285 + readmeContent string 286 + readmeFileName string 287 + ) 288 + 289 + // tags 290 + wg.Add(1) 291 + go func() { 292 + defer wg.Done() 293 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 294 + if err != nil { 295 + errs = errors.Join(errs, err) 296 + return 297 + } 298 + 299 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 300 + errs = errors.Join(errs, err) 301 + } 302 + }() 303 + 304 + // tree/files 305 + wg.Add(1) 306 + go func() { 307 + defer wg.Done() 308 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 309 + if err != nil { 310 + errs = errors.Join(errs, err) 311 + return 312 + } 313 + treeResp = resp 314 + }() 315 + 316 + // commits 317 + wg.Add(1) 318 + go func() { 319 + defer wg.Done() 320 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 321 + if err != nil { 322 + errs = errors.Join(errs, err) 323 + return 324 + } 325 + 326 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 327 + errs = errors.Join(errs, err) 328 + } 329 + }() 330 + 331 + // readme content 332 + wg.Add(1) 333 + go func() { 334 + defer wg.Done() 335 + for _, filename := range markup.ReadmeFilenames { 336 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 + if err != nil { 338 + continue 339 + } 340 + 341 + if blobResp == nil { 342 + continue 343 + } 344 + 345 + readmeContent = blobResp.Content 346 + readmeFileName = filename 347 + break 348 + } 349 + }() 350 + 351 + wg.Wait() 352 + 353 + if errs != nil { 354 + return nil, errs 355 + } 356 + 357 + var files []types.NiceTree 358 + if treeResp != nil && treeResp.Files != nil { 359 + for _, file := range treeResp.Files { 360 + niceFile := types.NiceTree{ 361 + IsFile: file.Is_file, 362 + IsSubtree: file.Is_subtree, 363 + Name: file.Name, 364 + Mode: file.Mode, 365 + Size: file.Size, 366 + } 367 + if file.Last_commit != nil { 368 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 369 + niceFile.LastCommit = &types.LastCommitInfo{ 370 + Hash: plumbing.NewHash(file.Last_commit.Hash), 371 + Message: file.Last_commit.Message, 372 + When: when, 373 + } 374 + } 375 + files = append(files, niceFile) 376 + } 377 + } 378 + 379 + result := &types.RepoIndexResponse{ 380 + IsEmpty: false, 381 + Ref: ref, 382 + Readme: readmeContent, 383 + ReadmeFileName: readmeFileName, 384 + Commits: logResp.Commits, 385 + Description: logResp.Description, 386 + Files: files, 387 + Branches: branchesResp.Branches, 388 + Tags: tagsResp.Tags, 389 + TotalCommits: logResp.Total, 390 + } 391 + 392 + return result, nil 393 + }
+374 -144
appview/repo/repo.go
··· 19 19 20 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 23 "tangled.sh/tangled.sh/core/api/tangled" 23 24 "tangled.sh/tangled.sh/core/appview/commitverify" 24 25 "tangled.sh/tangled.sh/core/appview/config" ··· 31 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 33 "tangled.sh/tangled.sh/core/eventconsumer" 33 34 "tangled.sh/tangled.sh/core/idresolver" 34 - "tangled.sh/tangled.sh/core/knotclient" 35 35 "tangled.sh/tangled.sh/core/patchutil" 36 36 "tangled.sh/tangled.sh/core/rbac" 37 37 "tangled.sh/tangled.sh/core/tid" ··· 92 92 return 93 93 } 94 94 95 - var uri string 96 - if rp.config.Core.Dev { 97 - uri = "http" 98 - } else { 99 - uri = "https" 95 + scheme := "http" 96 + if !rp.config.Core.Dev { 97 + scheme = "https" 98 + } 99 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 100 + xrpcc := &indigoxrpc.Client{ 101 + Host: host, 102 + } 103 + 104 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 105 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo) 106 + if err != nil { 107 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 108 + log.Println("failed to call XRPC repo.archive", xrpcerr) 109 + rp.pages.Error503(w) 110 + return 111 + } 112 + rp.pages.Error404(w) 113 + return 100 114 } 101 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 115 + 116 + // Set headers for file download 117 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam) 118 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 119 + w.Header().Set("Content-Type", "application/gzip") 120 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 102 121 103 - http.Redirect(w, r, url, http.StatusFound) 122 + // Write the archive data directly 123 + w.Write(archiveBytes) 104 124 } 105 125 106 126 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 120 140 121 141 ref := chi.URLParam(r, "ref") 122 142 123 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 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) 124 162 if err != nil { 125 - log.Println("failed to create unsigned client", err) 163 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 164 + log.Println("failed to call XRPC repo.log", xrpcerr) 165 + rp.pages.Error503(w) 166 + return 167 + } 168 + rp.pages.Error404(w) 126 169 return 127 170 } 128 171 129 - repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 - if err != nil { 172 + var xrpcResp types.RepoLogResponse 173 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 174 + log.Println("failed to decode XRPC response", err) 131 175 rp.pages.Error503(w) 132 - log.Println("failed to reach knotserver", err) 133 176 return 134 177 } 135 178 136 - tagResult, err := us.Tags(f.OwnerDid(), f.Name) 179 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 137 180 if err != nil { 138 - rp.pages.Error503(w) 139 - log.Println("failed to reach knotserver", err) 140 - return 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + log.Println("failed to call XRPC repo.tags", xrpcerr) 183 + rp.pages.Error503(w) 184 + return 185 + } 141 186 } 142 187 143 188 tagMap := make(map[string][]string) 144 - for _, tag := range tagResult.Tags { 145 - hash := tag.Hash 146 - if tag.Tag != nil { 147 - hash = tag.Tag.Target.String() 189 + if tagBytes != nil { 190 + var tagResp types.RepoTagsResponse 191 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 192 + for _, tag := range tagResp.Tags { 193 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 194 + } 148 195 } 149 - tagMap[hash] = append(tagMap[hash], tag.Name) 150 196 } 151 197 152 - branchResult, err := us.Branches(f.OwnerDid(), f.Name) 198 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 153 199 if err != nil { 154 - rp.pages.Error503(w) 155 - log.Println("failed to reach knotserver", err) 156 - return 200 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 201 + log.Println("failed to call XRPC repo.branches", xrpcerr) 202 + rp.pages.Error503(w) 203 + return 204 + } 157 205 } 158 206 159 - for _, branch := range branchResult.Branches { 160 - hash := branch.Hash 161 - tagMap[hash] = append(tagMap[hash], branch.Name) 207 + if branchBytes != nil { 208 + var branchResp types.RepoBranchesResponse 209 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 210 + for _, branch := range branchResp.Branches { 211 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 212 + } 213 + } 162 214 } 163 215 164 216 user := rp.oauth.GetUser(r) 165 217 166 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 218 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 167 219 if err != nil { 168 220 log.Println("failed to fetch email to did mapping", err) 169 221 } 170 222 171 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 223 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 172 224 if err != nil { 173 225 log.Println(err) 174 226 } ··· 176 228 repoInfo := f.RepoInfo(user) 177 229 178 230 var shas []string 179 - for _, c := range repolog.Commits { 231 + for _, c := range xrpcResp.Commits { 180 232 shas = append(shas, c.Hash.String()) 181 233 } 182 234 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 189 241 LoggedInUser: user, 190 242 TagMap: tagMap, 191 243 RepoInfo: repoInfo, 192 - RepoLogResponse: *repolog, 244 + RepoLogResponse: xrpcResp, 193 245 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 194 246 VerifiedCommits: vc, 195 247 Pipelines: pipelines, ··· 301 353 return 302 354 } 303 355 ref := chi.URLParam(r, "ref") 304 - protocol := "http" 305 - if !rp.config.Core.Dev { 306 - protocol = "https" 307 - } 308 356 309 357 var diffOpts types.DiffOpts 310 358 if d := r.URL.Query().Get("diff"); d == "split" { ··· 316 364 return 317 365 } 318 366 319 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 320 - if err != nil { 321 - rp.pages.Error503(w) 322 - log.Println("failed to reach knotserver", err) 323 - return 367 + scheme := "http" 368 + if !rp.config.Core.Dev { 369 + scheme = "https" 370 + } 371 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 372 + xrpcc := &indigoxrpc.Client{ 373 + Host: host, 324 374 } 325 375 326 - body, err := io.ReadAll(resp.Body) 376 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 377 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 327 378 if err != nil { 328 - log.Printf("Error reading response body: %v", err) 379 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 380 + log.Println("failed to call XRPC repo.diff", xrpcerr) 381 + rp.pages.Error503(w) 382 + return 383 + } 384 + rp.pages.Error404(w) 329 385 return 330 386 } 331 387 332 388 var result types.RepoCommitResponse 333 - err = json.Unmarshal(body, &result) 334 - if err != nil { 335 - log.Println("failed to parse response:", err) 389 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 390 + log.Println("failed to decode XRPC response", err) 391 + rp.pages.Error503(w) 336 392 return 337 393 } 338 394 ··· 378 434 379 435 ref := chi.URLParam(r, "ref") 380 436 treePath := chi.URLParam(r, "*") 381 - protocol := "http" 382 - if !rp.config.Core.Dev { 383 - protocol = "https" 384 - } 385 437 386 438 // if the tree path has a trailing slash, let's strip it 387 439 // so we don't 404 388 440 treePath = strings.TrimSuffix(treePath, "/") 389 441 390 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 442 + scheme := "http" 443 + if !rp.config.Core.Dev { 444 + scheme = "https" 445 + } 446 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 447 + xrpcc := &indigoxrpc.Client{ 448 + Host: host, 449 + } 450 + 451 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 452 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 391 453 if err != nil { 392 - rp.pages.Error503(w) 393 - log.Println("failed to reach knotserver", err) 454 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 455 + log.Println("failed to call XRPC repo.tree", xrpcerr) 456 + rp.pages.Error503(w) 457 + return 458 + } 459 + rp.pages.Error404(w) 394 460 return 395 461 } 396 462 397 - // uhhh so knotserver returns a 500 if the entry isn't found in 398 - // the requested tree path, so let's stick to not-OK here. 399 - // we can fix this once we build out the xrpc apis for these operations. 400 - if resp.StatusCode != http.StatusOK { 401 - rp.pages.Error404(w) 402 - return 463 + // Convert XRPC response to internal types.RepoTreeResponse 464 + files := make([]types.NiceTree, len(xrpcResp.Files)) 465 + for i, xrpcFile := range xrpcResp.Files { 466 + file := types.NiceTree{ 467 + Name: xrpcFile.Name, 468 + Mode: xrpcFile.Mode, 469 + Size: int64(xrpcFile.Size), 470 + IsFile: xrpcFile.Is_file, 471 + IsSubtree: xrpcFile.Is_subtree, 472 + } 473 + 474 + // Convert last commit info if present 475 + if xrpcFile.Last_commit != nil { 476 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 477 + file.LastCommit = &types.LastCommitInfo{ 478 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 479 + Message: xrpcFile.Last_commit.Message, 480 + When: commitWhen, 481 + } 482 + } 483 + 484 + files[i] = file 403 485 } 404 486 405 - body, err := io.ReadAll(resp.Body) 406 - if err != nil { 407 - log.Printf("Error reading response body: %v", err) 408 - return 487 + result := types.RepoTreeResponse{ 488 + Ref: xrpcResp.Ref, 489 + Files: files, 409 490 } 410 491 411 - var result types.RepoTreeResponse 412 - err = json.Unmarshal(body, &result) 413 - if err != nil { 414 - log.Println("failed to parse response:", err) 415 - return 492 + if xrpcResp.Parent != nil { 493 + result.Parent = *xrpcResp.Parent 494 + } 495 + if xrpcResp.Dotdot != nil { 496 + result.DotDot = *xrpcResp.Dotdot 416 497 } 417 498 418 499 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, ··· 451 532 return 452 533 } 453 534 454 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 535 + scheme := "http" 536 + if !rp.config.Core.Dev { 537 + scheme = "https" 538 + } 539 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 540 + xrpcc := &indigoxrpc.Client{ 541 + Host: host, 542 + } 543 + 544 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 545 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 455 546 if err != nil { 456 - log.Println("failed to create unsigned client", err) 547 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 548 + log.Println("failed to call XRPC repo.tags", xrpcerr) 549 + rp.pages.Error503(w) 550 + return 551 + } 552 + rp.pages.Error404(w) 457 553 return 458 554 } 459 555 460 - result, err := us.Tags(f.OwnerDid(), f.Name) 461 - if err != nil { 556 + var result types.RepoTagsResponse 557 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 558 + log.Println("failed to decode XRPC response", err) 462 559 rp.pages.Error503(w) 463 - log.Println("failed to reach knotserver", err) 464 560 return 465 561 } 466 562 ··· 496 592 rp.pages.RepoTags(w, pages.RepoTagsParams{ 497 593 LoggedInUser: user, 498 594 RepoInfo: f.RepoInfo(user), 499 - RepoTagsResponse: *result, 595 + RepoTagsResponse: result, 500 596 ArtifactMap: artifactMap, 501 597 DanglingArtifacts: danglingArtifacts, 502 598 }) ··· 509 605 return 510 606 } 511 607 512 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 608 + scheme := "http" 609 + if !rp.config.Core.Dev { 610 + scheme = "https" 611 + } 612 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 613 + xrpcc := &indigoxrpc.Client{ 614 + Host: host, 615 + } 616 + 617 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 618 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 513 619 if err != nil { 514 - log.Println("failed to create unsigned client", err) 620 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 621 + log.Println("failed to call XRPC repo.branches", xrpcerr) 622 + rp.pages.Error503(w) 623 + return 624 + } 625 + rp.pages.Error404(w) 515 626 return 516 627 } 517 628 518 - result, err := us.Branches(f.OwnerDid(), f.Name) 519 - if err != nil { 629 + var result types.RepoBranchesResponse 630 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 631 + log.Println("failed to decode XRPC response", err) 520 632 rp.pages.Error503(w) 521 - log.Println("failed to reach knotserver", err) 522 633 return 523 634 } 524 635 ··· 528 639 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 529 640 LoggedInUser: user, 530 641 RepoInfo: f.RepoInfo(user), 531 - RepoBranchesResponse: *result, 642 + RepoBranchesResponse: result, 532 643 }) 533 644 } 534 645 ··· 541 652 542 653 ref := chi.URLParam(r, "ref") 543 654 filePath := chi.URLParam(r, "*") 544 - protocol := "http" 655 + 656 + scheme := "http" 545 657 if !rp.config.Core.Dev { 546 - protocol = "https" 658 + scheme = "https" 547 659 } 548 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 549 - if err != nil { 550 - rp.pages.Error503(w) 551 - log.Println("failed to reach knotserver", err) 552 - return 660 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 661 + xrpcc := &indigoxrpc.Client{ 662 + Host: host, 553 663 } 554 664 555 - if resp.StatusCode == http.StatusNotFound { 665 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 666 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 667 + if err != nil { 668 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 669 + log.Println("failed to call XRPC repo.blob", xrpcerr) 670 + rp.pages.Error503(w) 671 + return 672 + } 556 673 rp.pages.Error404(w) 557 674 return 558 675 } 559 676 560 - body, err := io.ReadAll(resp.Body) 561 - if err != nil { 562 - log.Printf("Error reading response body: %v", err) 563 - return 564 - } 565 - 566 - var result types.RepoBlobResponse 567 - err = json.Unmarshal(body, &result) 568 - if err != nil { 569 - log.Println("failed to parse response:", err) 570 - return 571 - } 677 + // Use XRPC response directly instead of converting to internal types 572 678 573 679 var breadcrumbs [][]string 574 680 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) ··· 581 687 showRendered := false 582 688 renderToggle := false 583 689 584 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 690 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 585 691 renderToggle = true 586 692 showRendered = r.URL.Query().Get("code") != "true" 587 693 } ··· 591 697 var isVideo bool 592 698 var contentSrc string 593 699 594 - if result.IsBinary { 595 - ext := strings.ToLower(filepath.Ext(result.Path)) 700 + if resp.IsBinary != nil && *resp.IsBinary { 701 + ext := strings.ToLower(filepath.Ext(resp.Path)) 596 702 switch ext { 597 703 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 704 isImage = true ··· 602 708 unsupported = true 603 709 } 604 710 605 - // fetch the actual binary content like in RepoBlobRaw 711 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 712 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 713 + blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 714 + scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath)) 606 715 607 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 716 contentSrc = blobURL 609 717 if !rp.config.Core.Dev { 610 718 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 719 } 612 720 } 613 721 722 + lines := 0 723 + if resp.IsBinary == nil || !*resp.IsBinary { 724 + lines = strings.Count(resp.Content, "\n") + 1 725 + } 726 + 727 + var sizeHint uint64 728 + if resp.Size != nil { 729 + sizeHint = uint64(*resp.Size) 730 + } else { 731 + sizeHint = uint64(len(resp.Content)) 732 + } 733 + 614 734 user := rp.oauth.GetUser(r) 735 + 736 + // Determine if content is binary (dereference pointer) 737 + isBinary := false 738 + if resp.IsBinary != nil { 739 + isBinary = *resp.IsBinary 740 + } 741 + 615 742 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 616 - LoggedInUser: user, 617 - RepoInfo: f.RepoInfo(user), 618 - RepoBlobResponse: result, 619 - BreadCrumbs: breadcrumbs, 620 - ShowRendered: showRendered, 621 - RenderToggle: renderToggle, 622 - Unsupported: unsupported, 623 - IsImage: isImage, 624 - IsVideo: isVideo, 625 - ContentSrc: contentSrc, 743 + LoggedInUser: user, 744 + RepoInfo: f.RepoInfo(user), 745 + BreadCrumbs: breadcrumbs, 746 + ShowRendered: showRendered, 747 + RenderToggle: renderToggle, 748 + Unsupported: unsupported, 749 + IsImage: isImage, 750 + IsVideo: isVideo, 751 + ContentSrc: contentSrc, 752 + RepoBlob_Output: resp, 753 + Contents: resp.Content, 754 + Lines: lines, 755 + SizeHint: sizeHint, 756 + IsBinary: isBinary, 626 757 }) 627 758 } 628 759 ··· 637 768 ref := chi.URLParam(r, "ref") 638 769 filePath := chi.URLParam(r, "*") 639 770 640 - protocol := "http" 771 + scheme := "http" 641 772 if !rp.config.Core.Dev { 642 - protocol = "https" 773 + scheme = "https" 643 774 } 644 775 645 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 776 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 777 + blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 778 + scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath)) 646 779 647 780 req, err := http.NewRequest("GET", blobURL, nil) 648 781 if err != nil { ··· 685 818 return 686 819 } 687 820 688 - if strings.Contains(contentType, "text/plain") { 821 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 822 + // serve all textual content as text/plain 689 823 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 824 w.Write(body) 691 825 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 826 + // serve images and videos with their original content type 692 827 w.Header().Set("Content-Type", contentType) 693 828 w.Write(body) 694 829 } else { ··· 698 833 } 699 834 } 700 835 836 + // isTextualMimeType returns true if the MIME type represents textual content 837 + // that should be served as text/plain 838 + func isTextualMimeType(mimeType string) bool { 839 + textualTypes := []string{ 840 + "application/json", 841 + "application/xml", 842 + "application/yaml", 843 + "application/x-yaml", 844 + "application/toml", 845 + "application/javascript", 846 + "application/ecmascript", 847 + "message/", 848 + } 849 + 850 + return slices.Contains(textualTypes, mimeType) 851 + } 852 + 701 853 // modify the spindle configured for this repo 702 854 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 703 855 user := rp.oauth.GetUser(r) ··· 1201 1353 f, err := rp.repoResolver.Resolve(r) 1202 1354 user := rp.oauth.GetUser(r) 1203 1355 1204 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1356 + scheme := "http" 1357 + if !rp.config.Core.Dev { 1358 + scheme = "https" 1359 + } 1360 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1361 + xrpcc := &indigoxrpc.Client{ 1362 + Host: host, 1363 + } 1364 + 1365 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1366 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1205 1367 if err != nil { 1206 - log.Println("failed to create unsigned client", err) 1368 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1369 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1370 + rp.pages.Error503(w) 1371 + return 1372 + } 1373 + rp.pages.Error503(w) 1207 1374 return 1208 1375 } 1209 1376 1210 - result, err := us.Branches(f.OwnerDid(), f.Name) 1211 - if err != nil { 1377 + var result types.RepoBranchesResponse 1378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1379 + log.Println("failed to decode XRPC response", err) 1212 1380 rp.pages.Error503(w) 1213 - log.Println("failed to reach knotserver", err) 1214 1381 return 1215 1382 } 1216 1383 ··· 1581 1748 return 1582 1749 } 1583 1750 1584 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1751 + scheme := "http" 1752 + if !rp.config.Core.Dev { 1753 + scheme = "https" 1754 + } 1755 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1756 + xrpcc := &indigoxrpc.Client{ 1757 + Host: host, 1758 + } 1759 + 1760 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1761 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1585 1762 if err != nil { 1586 - log.Printf("failed to create unsigned client for %s", f.Knot) 1587 - rp.pages.Error503(w) 1763 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1764 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1765 + rp.pages.Error503(w) 1766 + return 1767 + } 1768 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1588 1769 return 1589 1770 } 1590 1771 1591 - result, err := us.Branches(f.OwnerDid(), f.Name) 1592 - if err != nil { 1772 + var branchResult types.RepoBranchesResponse 1773 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1774 + log.Println("failed to decode XRPC branches response", err) 1593 1775 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1594 - log.Println("failed to reach knotserver", err) 1595 1776 return 1596 1777 } 1597 - branches := result.Branches 1778 + branches := branchResult.Branches 1598 1779 1599 1780 sortBranches(branches) 1600 1781 ··· 1618 1799 head = queryHead 1619 1800 } 1620 1801 1621 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1802 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1622 1803 if err != nil { 1804 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1805 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1806 + rp.pages.Error503(w) 1807 + return 1808 + } 1623 1809 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 - log.Println("failed to reach knotserver", err) 1810 + return 1811 + } 1812 + 1813 + var tags types.RepoTagsResponse 1814 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1815 + log.Println("failed to decode XRPC tags response", err) 1816 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1625 1817 return 1626 1818 } 1627 1819 ··· 1673 1865 return 1674 1866 } 1675 1867 1676 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1868 + scheme := "http" 1869 + if !rp.config.Core.Dev { 1870 + scheme = "https" 1871 + } 1872 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1873 + xrpcc := &indigoxrpc.Client{ 1874 + Host: host, 1875 + } 1876 + 1877 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1878 + 1879 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1677 1880 if err != nil { 1678 - log.Printf("failed to create unsigned client for %s", f.Knot) 1679 - rp.pages.Error503(w) 1881 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1882 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1883 + rp.pages.Error503(w) 1884 + return 1885 + } 1886 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1680 1887 return 1681 1888 } 1682 1889 1683 - branches, err := us.Branches(f.OwnerDid(), f.Name) 1684 - if err != nil { 1890 + var branches types.RepoBranchesResponse 1891 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1892 + log.Println("failed to decode XRPC branches response", err) 1685 1893 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1686 - log.Println("failed to reach knotserver", err) 1687 1894 return 1688 1895 } 1689 1896 1690 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1897 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1691 1898 if err != nil { 1899 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1900 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1901 + rp.pages.Error503(w) 1902 + return 1903 + } 1692 1904 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1693 - log.Println("failed to reach knotserver", err) 1694 1905 return 1695 1906 } 1696 1907 1697 - formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1908 + var tags types.RepoTagsResponse 1909 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1910 + log.Println("failed to decode XRPC tags response", err) 1911 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1912 + return 1913 + } 1914 + 1915 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1698 1916 if err != nil { 1917 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1918 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1919 + rp.pages.Error503(w) 1920 + return 1921 + } 1699 1922 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1700 - log.Println("failed to compare", err) 1701 1923 return 1702 1924 } 1925 + 1926 + var formatPatch types.RepoFormatPatchResponse 1927 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1928 + log.Println("failed to decode XRPC compare response", err) 1929 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1930 + return 1931 + } 1932 + 1703 1933 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1704 1934 1705 1935 repoinfo := f.RepoInfo(user)
+11 -27
appview/serververify/verify.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 7 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 12 10 "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 13 12 "tangled.sh/tangled.sh/core/rbac" 14 13 ) 15 14 ··· 24 23 scheme = "http" 25 24 } 26 25 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") 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 40 29 } 41 30 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) 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 45 34 } 46 35 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 36 + return res.Owner, nil 53 37 } 54 38 55 39 type OwnerMismatch struct { ··· 65 49 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 50 observedOwner, err := fetchOwner(ctx, domain, dev) 67 51 if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 52 + return err 69 53 } 70 54 71 55 if observedOwner != expectedOwner {
+4 -3
appview/spindles/spindles.go
··· 16 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 17 "tangled.sh/tangled.sh/core/appview/pages" 18 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" ··· 404 405 if err != nil { 405 406 l.Error("verification failed", "err", err) 406 407 407 - if errors.Is(err, serververify.FetchError) { 408 - s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 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 ··· 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) {
+197 -137
appview/state/profile.go
··· 17 17 "github.com/gorilla/feeds" 18 18 "tangled.sh/tangled.sh/core/api/tangled" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 ) 23 22 24 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 24 tabVal := r.URL.Query().Get("tab") 26 25 switch tabVal { 27 - case "": 28 - s.profileHomePage(w, r) 29 26 case "repos": 30 27 s.reposPage(w, r) 31 28 case "followers": 32 29 s.followersPage(w, r) 33 30 case "following": 34 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) 35 38 } 36 39 } 37 40 38 - type ProfilePageParams struct { 39 - Id identity.Identity 40 - LoggedInUser *oauth.User 41 - Card pages.ProfileCard 42 - } 43 - 44 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 45 42 didOrHandle := chi.URLParam(r, "user") 46 43 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 44 + return nil, fmt.Errorf("empty DID or handle") 49 45 } 50 46 51 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 48 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 49 + return nil, fmt.Errorf("failed to resolve ID") 56 50 } 57 51 did := ident.DID.String() 58 52 59 53 profile, err := db.GetProfile(s.db, did) 60 54 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 55 + return nil, fmt.Errorf("failed to get profile: %w", err) 56 + } 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) 64 71 } 65 72 66 73 followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 74 if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 69 76 } 70 77 71 78 loggedInUser := s.oauth.GetUser(r) ··· 74 81 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 82 } 76 83 77 - return &ProfilePageParams{ 78 - Id: ident, 79 - LoggedInUser: loggedInUser, 80 - Card: pages.ProfileCard{ 81 - UserDid: did, 82 - UserHandle: ident.Handle.String(), 83 - Profile: profile, 84 - FollowStatus: followStatus, 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, 85 105 FollowersCount: followStats.Followers, 86 106 FollowingCount: followStats.Following, 87 107 }, 88 - } 108 + Punchcard: punchcard, 109 + }, nil 89 110 } 90 111 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 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) 94 119 return 95 120 } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 96 122 97 - id := pageWithProfile.Id 98 123 repos, err := db.GetRepos( 99 124 s.db, 100 125 0, 101 - db.FilterEq("did", id.DID), 126 + db.FilterEq("did", profile.UserDid), 102 127 ) 103 128 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 129 + l.Error("failed to fetch repos", "err", err) 105 130 } 106 131 107 - profile := pageWithProfile.Card.Profile 108 132 // filter out ones that are pinned 109 133 pinnedRepos := []db.Repo{} 110 134 for i, r := range repos { 111 135 // if this is a pinned repo, add it 112 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 113 137 pinnedRepos = append(pinnedRepos, r) 114 138 } 115 139 116 140 // if there are no saved pins, add the first 4 repos 117 - if profile.IsPinnedReposEmpty() && i < 4 { 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 118 142 pinnedRepos = append(pinnedRepos, r) 119 143 } 120 144 } 121 145 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 123 147 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 148 + l.Error("failed to fetch collaborating repos", "err", err) 125 149 } 126 150 127 151 pinnedCollaboratingRepos := []db.Repo{} 128 152 for _, r := range collaboratingRepos { 129 153 // if this is a pinned repo, add it 130 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 131 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 132 156 } 133 157 } 134 158 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 136 160 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 161 + l.Error("failed to create timeline", "err", err) 138 162 } 139 163 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 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") 175 + 176 + profile, err := s.profile(r) 177 + if err != nil { 178 + l.Error("failed to build profile card", "err", err) 179 + s.pages.Error500(w) 180 + return 143 181 } 144 - for _, byMonth := range timeline.ByMonth { 145 - for _, pe := range byMonth.PullEvents.Items { 146 - didsToResolve = append(didsToResolve, pe.Repo.Did) 147 - } 148 - for _, ie := range byMonth.IssueEvents.Items { 149 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 150 - } 151 - for _, re := range byMonth.RepoEvents { 152 - didsToResolve = append(didsToResolve, re.Repo.Did) 153 - if re.Source != nil { 154 - didsToResolve = append(didsToResolve, re.Source.Did) 155 - } 156 - } 157 - } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 158 183 159 - now := time.Now() 160 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 - punchcard, err := db.MakePunchcard( 184 + repos, err := db.GetRepos( 162 185 s.db, 163 - db.FilterEq("did", id.DID), 164 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 - db.FilterLte("date", now.Format(time.DateOnly)), 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 166 188 ) 167 189 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 169 193 } 170 194 171 - s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 - LoggedInUser: pageWithProfile.LoggedInUser, 173 - Repos: pinnedRepos, 174 - CollaboratingRepos: pinnedCollaboratingRepos, 175 - Card: pageWithProfile.Card, 176 - Punchcard: punchcard, 177 - ProfileTimeline: timeline, 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 178 199 }) 179 200 } 180 201 181 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 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) 184 209 return 185 210 } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 186 212 187 - id := pageWithProfile.Id 213 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 214 + if err != nil { 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)) 222 + } 223 + 188 224 repos, err := db.GetRepos( 189 225 s.db, 190 226 0, 191 - db.FilterEq("did", id.DID), 227 + db.FilterIn("at_uri", repoAts), 192 228 ) 193 229 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 195 233 } 196 234 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 199 237 Repos: repos, 200 - Card: pageWithProfile.Card, 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 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, 201 264 }) 202 265 } 203 266 204 267 type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 208 270 } 209 271 210 - func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 - pageWithProfile := s.profilePage(w, r) 212 - if pageWithProfile == nil { 213 - return FollowsPageParams{}, nil 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 214 282 } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 215 284 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 285 + loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 218 289 219 - follows, err := fetchFollows(s.db, id.DID.String()) 290 + follows, err := fetchFollows(s.db, profile.UserDid) 220 291 if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 223 294 } 224 295 225 296 if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 297 + return &params, nil 231 298 } 232 299 233 300 followDids := make([]string, 0, len(follows)) ··· 237 304 238 305 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 306 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 242 309 } 243 310 244 311 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 246 313 log.Printf("getting follow counts for %s: %s", followDids, err) 247 314 } 248 315 249 - var loggedInUserFollowing map[string]struct{} 316 + loggedInUserFollowing := make(map[string]struct{}) 250 317 if loggedInUser != nil { 251 318 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 319 if err != nil { 253 - return FollowsPageParams{}, err 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 254 322 } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 260 326 } 261 327 } 262 328 263 - followCards := make([]pages.FollowCard, 0, len(follows)) 264 - for _, did := range followDids { 265 - followStats, exists := followStatsMap[did] 266 - if !exists { 267 - followStats = db.FollowStats{} 268 - } 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 269 332 followStatus := db.IsNotFollowing 270 - if loggedInUserFollowing != nil { 271 - if _, exists := loggedInUserFollowing[did]; exists { 272 - followStatus = db.IsFollowing 273 - } else if loggedInUser.Did == did { 274 - followStatus = db.IsSelf 275 - } 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 276 337 } 338 + 277 339 var profile *db.Profile 278 340 if p, exists := profiles[did]; exists { 279 341 profile = p ··· 281 343 profile = &db.Profile{} 282 344 profile.Did = did 283 345 } 284 - followCards = append(followCards, pages.FollowCard{ 346 + followCards[i] = pages.FollowCard{ 285 347 UserDid: did, 286 348 FollowStatus: followStatus, 287 349 FollowersCount: followStats.Followers, 288 350 FollowingCount: followStats.Following, 289 351 Profile: profile, 290 - }) 352 + } 291 353 } 292 354 293 - return FollowsPageParams{ 294 - LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 355 + params.Follows = followCards 356 + 357 + return &params, nil 298 358 } 299 359 300 360 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 - followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 362 if err != nil { 303 363 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 364 return 305 365 } 306 366 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 309 369 Followers: followPage.Follows, 310 370 Card: followPage.Card, 311 371 }) 312 372 } 313 373 314 374 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 - followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 376 if err != nil { 317 377 s.pages.Notice(w, "all-following", "Failed to load following") 318 378 return 319 379 } 320 380 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 323 383 Following: followPage.Follows, 324 384 Card: followPage.Card, 325 385 }) ··· 408 468 409 469 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 470 for _, issue := range issues { 411 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 412 472 if err != nil { 413 473 return err 414 474 } ··· 440 500 441 501 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 502 return &feeds.Item{ 443 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 444 - 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"}, 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"}, 445 505 Created: issue.Created, 446 506 Author: author, 447 507 } ··· 642 702 log.Printf("getting profile data for %s: %s", user.Did, err) 643 703 } 644 704 645 - repos, err := db.GetAllReposByDid(s.db, user.Did) 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 646 706 if err != nil { 647 707 log.Printf("getting repos for %s: %s", user.Did, err) 648 708 }
+4 -2
appview/state/router.go
··· 111 111 112 112 r.Handle("/static/*", s.pages.Static()) 113 113 114 - 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) 115 117 116 118 r.Route("/repo", func(r chi.Router) { 117 119 r.Route("/new", func(r chi.Router) { ··· 230 232 } 231 233 232 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 233 - 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) 234 236 return issues.Router(mw) 235 237 } 236 238
+78 -4
appview/state/state.go
··· 28 28 "tangled.sh/tangled.sh/core/appview/pages" 29 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 31 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 33 "tangled.sh/tangled.sh/core/eventconsumer" 33 34 "tangled.sh/tangled.sh/core/idresolver" ··· 53 54 knotstream *eventconsumer.Consumer 54 55 spindlestream *eventconsumer.Consumer 55 56 logger *slog.Logger 57 + validator *validator.Validator 56 58 } 57 59 58 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 73 75 } 74 76 75 77 pgs := pages.NewPages(config, res) 76 - 77 78 cache := cache.New(config.Redis.Addr) 78 79 sess := session.New(cache) 79 - 80 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 81 82 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 83 84 if err != nil { ··· 99 100 tangled.SpindleMemberNSID, 100 101 tangled.SpindleNSID, 101 102 tangled.StringNSID, 103 + tangled.RepoIssueNSID, 104 + tangled.RepoIssueCommentNSID, 102 105 }, 103 106 nil, 104 107 slog.Default(), ··· 119 122 IdResolver: res, 120 123 Config: config, 121 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 122 126 } 123 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 124 128 if err != nil { ··· 158 162 knotstream, 159 163 spindlestream, 160 164 slog.Default(), 165 + validator, 161 166 } 162 167 163 168 return state, nil 164 169 } 165 170 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 174 + } 175 + 166 176 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 167 177 w.Header().Set("Content-Type", "image/svg+xml") 168 178 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 190 200 }) 191 201 } 192 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 + 193 211 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 194 212 user := s.oauth.GetUser(r) 195 213 196 - timeline, err := db.MakeTimeline(s.db) 214 + timeline, err := db.MakeTimeline(s.db, 50) 197 215 if err != nil { 198 216 log.Println(err) 199 217 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 213 231 }) 214 232 } 215 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, 287 + }) 288 + } 289 + 216 290 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 217 291 user := chi.URLParam(r, "user") 218 292 user = strings.TrimPrefix(user, "@") ··· 241 315 242 316 for _, k := range pubKeys { 243 317 key := strings.TrimRight(k.Key, "\n") 244 - w.Write([]byte(fmt.Sprintln(key))) 318 + fmt.Fprintln(w, key) 245 319 } 246 320 } 247 321
+1 -59
appview/strings/strings.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "path" 8 - "slices" 9 8 "strconv" 10 9 "time" 11 10 ··· 161 160 } 162 161 163 162 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 - l := s.Logger.With("handler", "dashboard") 165 - 166 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 - if !ok { 168 - l.Error("malformed middleware") 169 - w.WriteHeader(http.StatusInternalServerError) 170 - return 171 - } 172 - l = l.With("did", id.DID, "handle", id.Handle) 173 - 174 - all, err := db.GetStrings( 175 - s.Db, 176 - 0, 177 - db.FilterEq("did", id.DID), 178 - ) 179 - if err != nil { 180 - l.Error("failed to fetch strings", "err", err) 181 - w.WriteHeader(http.StatusInternalServerError) 182 - return 183 - } 184 - 185 - slices.SortFunc(all, func(a, b db.String) int { 186 - if a.Created.After(b.Created) { 187 - return -1 188 - } else { 189 - return 1 190 - } 191 - }) 192 - 193 - profile, err := db.GetProfile(s.Db, id.DID.String()) 194 - if err != nil { 195 - l.Error("failed to fetch user profile", "err", err) 196 - w.WriteHeader(http.StatusInternalServerError) 197 - return 198 - } 199 - loggedInUser := s.OAuth.GetUser(r) 200 - followStatus := db.IsNotFollowing 201 - if loggedInUser != nil { 202 - followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 - } 204 - 205 - followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 - if err != nil { 207 - l.Error("failed to get follow stats", "err", err) 208 - } 209 - 210 - s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 - LoggedInUser: s.OAuth.GetUser(r), 212 - Card: pages.ProfileCard{ 213 - UserDid: id.DID.String(), 214 - UserHandle: id.Handle.String(), 215 - Profile: profile, 216 - FollowStatus: followStatus, 217 - FollowersCount: followStats.Followers, 218 - FollowingCount: followStats.Following, 219 - }, 220 - Strings: all, 221 - }) 163 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 222 164 } 223 165 224 166 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+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 + }
+11 -5
appview/xrpcclient/xrpc.go
··· 4 4 "bytes" 5 5 "context" 6 6 "errors" 7 - "fmt" 8 7 "io" 9 8 "net/http" 10 9 ··· 12 11 "github.com/bluesky-social/indigo/xrpc" 13 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 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") 15 21 ) 16 22 17 23 type Client struct { ··· 115 121 116 122 var xrpcerr *indigoxrpc.Error 117 123 if ok := errors.As(err, &xrpcerr); !ok { 118 - return fmt.Errorf("Recieved invalid XRPC error response.") 124 + return ErrXrpcInvalid 119 125 } 120 126 121 127 switch xrpcerr.StatusCode { 122 128 case http.StatusNotFound: 123 - return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 129 + return ErrXrpcUnsupported 124 130 case http.StatusUnauthorized: 125 - return fmt.Errorf("Unauthorized XRPC request.") 131 + return ErrXrpcUnauthorized 126 132 default: 127 - return fmt.Errorf("Failed to perform operation. Try again later.") 133 + return ErrXrpcFailed 128 134 } 129 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)
+5 -4
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 27 tangled.Knot{}, 28 28 tangled.KnotMember{}, ··· 47 47 tangled.RepoPullComment{}, 48 48 tangled.RepoPull_Source{}, 49 49 tangled.RepoPullStatus{}, 50 + tangled.RepoPull_Target{}, 50 51 tangled.Spindle{}, 51 52 tangled.SpindleMember{}, 52 53 tangled.String{},
+3 -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
+53 -12
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, grab your DID from http://localhost:3000/settings. 59 - Then, set `TANGLED_VM_KNOT_OWNER` and 60 - `TANGLED_VM_SPINDLE_OWNER` to your DID. 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`. 61 90 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 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). 64 96 65 - You can now start a lightweight NixOS VM like so: 97 + </details> 98 + 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: 66 103 67 104 ```bash 68 105 nix run --impure .#vm ··· 74 111 with `ssh` exposed on port 2222. 75 112 76 113 Once the services are running, head to 77 - http://localhost:3000/knots and hit verify (and similarly, 78 - http://localhost:3000/spindles to verify your spindle). It 79 - should verify the ownership of the services instantly if 80 - everything went smoothly. 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 81 117 82 118 You can push repositories to this VM with this ssh config 83 119 block on your main machine: ··· 97 133 git push local-dev main 98 134 ``` 99 135 100 - ## running a spindle 136 + ### running a spindle 101 137 102 138 The above VM should already be running a spindle on 103 139 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 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`.
-35
docs/migrations/knot-1.7.0.md
··· 1 - # Upgrading from v1.7.0 2 - 3 - After v1.7.0, knot secrets have been deprecated. You no 4 - longer need a secret from the appview to run a knot. All 5 - authorized commands to knots are managed via [Inter-Service 6 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 - Knots will be read-only until upgraded. 8 - 9 - Upgrading is quite easy, in essence: 10 - 11 - - `KNOT_SERVER_SECRET` is no more, you can remove this 12 - environment variable entirely 13 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 - your DID. You can find your DID in the 15 - [settings](https://tangled.sh/settings) page. 16 - - Restart your knot once you have replaced the environment 17 - variable 18 - - Head to the [knot dashboard](https://tangled.sh/knots) and 19 - hit the "retry" button to verify your knot. This simply 20 - writes a `sh.tangled.knot` record to your PDS. 21 - 22 - ## Nix 23 - 24 - If you use the nix module, simply bump the flake to the 25 - latest revision, and change your config block like so: 26 - 27 - ```diff 28 - services.tangled-knot = { 29 - enable = true; 30 - server = { 31 - - secretFile = /path/to/secret; 32 - + owner = "did:plc:foo"; 33 - }; 34 - }; 35 - ```
+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 +
+130 -54
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 - * Workflows can run using different *engines*. 14 + ## Trigger 8 15 9 - The most barebones workflow looks like this: 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: 10 25 11 26 ```yaml 12 27 when: 13 - - event: ["push"] 28 + - event: ["push", "manual"] 29 + branch: ["main", "develop"] 30 + - event: ["pull_request"] 14 31 branch: ["main"] 32 + ``` 15 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 16 43 engine: "nixery" 44 + ``` 45 + 46 + ## Clone options 17 47 18 - # optional 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 19 57 clone: 20 58 skip: false 21 - depth: 50 22 - submodules: true 59 + depth: 1 60 + submodules: false 23 61 ``` 24 62 25 - The `when` and `engine` fields are required, while every other aspect 26 - of how the definition is parsed is up to the engine. Currently, a spindle 27 - provides at least one of these built-in engines: 63 + ## Dependencies 28 64 29 - ## `nixery` 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. 30 66 31 - The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run 32 - steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs). 33 - 34 - Here's an example that uses all fields: 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: 35 68 36 69 ```yaml 37 - # build_and_test.yaml 38 - when: 39 - - event: ["push", "pull_request"] 40 - branch: ["main", "develop"] 41 - - event: ["manual"] 42 - 43 70 dependencies: 44 - ## from nixpkgs 71 + # nixpkgs 45 72 nixpkgs: 46 73 - nodejs 47 - ## custom registry 48 - git+https://tangled.sh/@oppi.li/statix: 49 - - statix 74 + - go 75 + # custom registry 76 + git+https://tangled.sh/@example.com/my_pkg: 77 + - my_pkg 78 + ``` 50 79 51 - steps: 52 - - name: "Install dependencies" 53 - command: "npm install" 54 - environment: 55 - NODE_ENV: "development" 56 - CI: "true" 80 + Now these dependencies are available to use in your workflow! 57 81 58 - - name: "Run linter" 59 - command: "npm run lint" 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 + ``` 60 95 61 - - name: "Run tests" 62 - command: "npm test" 63 - environment: 64 - NODE_ENV: "test" 65 - JEST_WORKERS: "2" 96 + ## Steps 66 97 67 - - name: "Build application" 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.** 103 + 104 + Example: 105 + 106 + ```yaml 107 + steps: 108 + - name: "Build backend" 109 + command: "go build" 110 + environment: 111 + GOOS: "darwin" 112 + GOARCH: "arm64" 113 + - name: "Build frontend" 68 114 command: "npm run build" 69 115 environment: 70 116 NODE_ENV: "production" 117 + ``` 71 118 72 - environment: 73 - BUILD_NUMBER: "123" 74 - GIT_BRANCH: "main" 119 + ## Complete workflow 75 120 76 - ## current repository is cloned and checked out at the target ref 77 - ## 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 78 133 clone: 79 134 skip: false 80 - depth: 50 81 - submodules: true 82 - ``` 135 + depth: 1 136 + submodules: false 83 137 84 - ## git push options 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 85 146 86 - These are push options that can be used with the `--push-option (-o)` flag of git push: 147 + environment: 148 + GOOS: "linux" 149 + GOARCH: "arm64" 150 + NODE_ENV: "production" 151 + MY_ENV_VAR: "MY_ENV_VALUE" 152 + 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 + ``` 87 164 88 - - `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push. 89 - - `skip-ci`, `ci-skip`: skips triggering the CI pipeline. 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).
+3 -1
go.mod
··· 39 39 github.com/stretchr/testify v1.10.0 40 40 github.com/urfave/cli/v3 v3.3.3 41 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 - github.com/yuin/goldmark v1.4.15 42 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.12 43 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 44 45 golang.org/x/crypto v0.40.0 45 46 golang.org/x/net v0.42.0 ··· 154 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 155 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 156 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 157 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 158 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 159 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+6 -1
go.sum
··· 426 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 427 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 428 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= 429 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 430 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 431 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 432 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 433 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 434 - github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= 435 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= 436 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 437 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 438 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
+1 -1
input.css
··· 90 90 } 91 91 92 92 label { 93 - @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; 94 94 } 95 95 input { 96 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;
-285
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 - } 251 - 252 - func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 - const ( 254 - Method = "GET" 255 - ) 256 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 - 258 - req, err := s.newRequest(Method, endpoint, nil, nil) 259 - if err != nil { 260 - return nil, err 261 - } 262 - 263 - resp, err := s.client.Do(req) 264 - if err != nil { 265 - return nil, err 266 - } 267 - 268 - var result types.RepoLanguageResponse 269 - if resp.StatusCode != http.StatusOK { 270 - log.Println("failed to calculate languages", resp.Status) 271 - return &types.RepoLanguageResponse{}, nil 272 - } 273 - 274 - body, err := io.ReadAll(resp.Body) 275 - if err != nil { 276 - return nil, err 277 - } 278 - 279 - err = json.Unmarshal(body, &result) 280 - if err != nil { 281 - return nil, err 282 - } 283 - 284 - return &result, nil 285 - }
+7
knotserver/config/config.go
··· 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
+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 - }
+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 }
+4 -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")
-1069
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/sha256" 7 - "encoding/json" 8 - "errors" 9 - "fmt" 10 - "log" 11 - "net/http" 12 - "net/url" 13 - "path/filepath" 14 - "strconv" 15 - "strings" 16 - "sync" 17 - "time" 18 - 19 - securejoin "github.com/cyphar/filepath-securejoin" 20 - "github.com/gliderlabs/ssh" 21 - "github.com/go-chi/chi/v5" 22 - "github.com/go-git/go-git/v5/plumbing" 23 - "github.com/go-git/go-git/v5/plumbing/object" 24 - "tangled.sh/tangled.sh/core/knotserver/db" 25 - "tangled.sh/tangled.sh/core/knotserver/git" 26 - "tangled.sh/tangled.sh/core/types" 27 - ) 28 - 29 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 - } 32 - 33 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 - w.Header().Set("Content-Type", "application/json") 35 - 36 - capabilities := map[string]any{ 37 - "pull_requests": map[string]any{ 38 - "format_patch": true, 39 - "patch_submissions": true, 40 - "branch_submissions": true, 41 - "fork_submissions": true, 42 - }, 43 - "xrpc": true, 44 - } 45 - 46 - jsonData, err := json.Marshal(capabilities) 47 - if err != nil { 48 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 - return 50 - } 51 - 52 - w.Write(jsonData) 53 - } 54 - 55 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 - l := h.l.With("path", path, "handler", "RepoIndex") 58 - ref := chi.URLParam(r, "ref") 59 - ref, _ = url.PathUnescape(ref) 60 - 61 - gr, err := git.Open(path, ref) 62 - if err != nil { 63 - plain, err2 := git.PlainOpen(path) 64 - if err2 != nil { 65 - l.Error("opening repo", "error", err2.Error()) 66 - notFound(w) 67 - return 68 - } 69 - branches, _ := plain.Branches() 70 - 71 - log.Println(err) 72 - 73 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 - resp := types.RepoIndexResponse{ 75 - IsEmpty: true, 76 - Branches: branches, 77 - } 78 - writeJSON(w, resp) 79 - return 80 - } else { 81 - l.Error("opening repo", "error", err.Error()) 82 - notFound(w) 83 - return 84 - } 85 - } 86 - 87 - var ( 88 - commits []*object.Commit 89 - total int 90 - branches []types.Branch 91 - files []types.NiceTree 92 - tags []object.Tag 93 - ) 94 - 95 - var wg sync.WaitGroup 96 - errorsCh := make(chan error, 5) 97 - 98 - wg.Add(1) 99 - go func() { 100 - defer wg.Done() 101 - cs, err := gr.Commits(0, 60) 102 - if err != nil { 103 - errorsCh <- fmt.Errorf("commits: %w", err) 104 - return 105 - } 106 - commits = cs 107 - }() 108 - 109 - wg.Add(1) 110 - go func() { 111 - defer wg.Done() 112 - t, err := gr.TotalCommits() 113 - if err != nil { 114 - errorsCh <- fmt.Errorf("calculating total: %w", err) 115 - return 116 - } 117 - total = t 118 - }() 119 - 120 - wg.Add(1) 121 - go func() { 122 - defer wg.Done() 123 - bs, err := gr.Branches() 124 - if err != nil { 125 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 - return 127 - } 128 - branches = bs 129 - }() 130 - 131 - wg.Add(1) 132 - go func() { 133 - defer wg.Done() 134 - ts, err := gr.Tags() 135 - if err != nil { 136 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 - return 138 - } 139 - tags = ts 140 - }() 141 - 142 - wg.Add(1) 143 - go func() { 144 - defer wg.Done() 145 - fs, err := gr.FileTree(r.Context(), "") 146 - if err != nil { 147 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 - return 149 - } 150 - files = fs 151 - }() 152 - 153 - wg.Wait() 154 - close(errorsCh) 155 - 156 - // show any errors 157 - for err := range errorsCh { 158 - l.Error("loading repo", "error", err.Error()) 159 - writeError(w, err.Error(), http.StatusInternalServerError) 160 - return 161 - } 162 - 163 - rtags := []*types.TagReference{} 164 - for _, tag := range tags { 165 - var target *object.Tag 166 - if tag.Target != plumbing.ZeroHash { 167 - target = &tag 168 - } 169 - tr := types.TagReference{ 170 - Tag: target, 171 - } 172 - 173 - tr.Reference = types.Reference{ 174 - Name: tag.Name, 175 - Hash: tag.Hash.String(), 176 - } 177 - 178 - if tag.Message != "" { 179 - tr.Message = tag.Message 180 - } 181 - 182 - rtags = append(rtags, &tr) 183 - } 184 - 185 - var readmeContent string 186 - var readmeFile string 187 - for _, readme := range h.c.Repo.Readme { 188 - content, _ := gr.FileContent(readme) 189 - if len(content) > 0 { 190 - readmeContent = string(content) 191 - readmeFile = readme 192 - } 193 - } 194 - 195 - if ref == "" { 196 - mainBranch, err := gr.FindMainBranch() 197 - if err != nil { 198 - writeError(w, err.Error(), http.StatusInternalServerError) 199 - l.Error("finding main branch", "error", err.Error()) 200 - return 201 - } 202 - ref = mainBranch 203 - } 204 - 205 - resp := types.RepoIndexResponse{ 206 - IsEmpty: false, 207 - Ref: ref, 208 - Commits: commits, 209 - Description: getDescription(path), 210 - Readme: readmeContent, 211 - ReadmeFileName: readmeFile, 212 - Files: files, 213 - Branches: branches, 214 - Tags: rtags, 215 - TotalCommits: total, 216 - } 217 - 218 - writeJSON(w, resp) 219 - } 220 - 221 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 - treePath := chi.URLParam(r, "*") 223 - ref := chi.URLParam(r, "ref") 224 - ref, _ = url.PathUnescape(ref) 225 - 226 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 - 228 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 - gr, err := git.Open(path, ref) 230 - if err != nil { 231 - notFound(w) 232 - return 233 - } 234 - 235 - files, err := gr.FileTree(r.Context(), treePath) 236 - if err != nil { 237 - writeError(w, err.Error(), http.StatusInternalServerError) 238 - l.Error("file tree", "error", err.Error()) 239 - return 240 - } 241 - 242 - resp := types.RepoTreeResponse{ 243 - Ref: ref, 244 - Parent: treePath, 245 - Description: getDescription(path), 246 - DotDot: filepath.Dir(treePath), 247 - Files: files, 248 - } 249 - 250 - writeJSON(w, resp) 251 - } 252 - 253 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 - treePath := chi.URLParam(r, "*") 255 - ref := chi.URLParam(r, "ref") 256 - ref, _ = url.PathUnescape(ref) 257 - 258 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 - 260 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 - gr, err := git.Open(path, ref) 262 - if err != nil { 263 - notFound(w) 264 - return 265 - } 266 - 267 - contents, err := gr.RawContent(treePath) 268 - if err != nil { 269 - writeError(w, err.Error(), http.StatusBadRequest) 270 - l.Error("file content", "error", err.Error()) 271 - return 272 - } 273 - 274 - mimeType := http.DetectContentType(contents) 275 - 276 - // exception for svg 277 - if filepath.Ext(treePath) == ".svg" { 278 - mimeType = "image/svg+xml" 279 - } 280 - 281 - contentHash := sha256.Sum256(contents) 282 - eTag := fmt.Sprintf("\"%x\"", contentHash) 283 - 284 - // allow image, video, and text/plain files to be served directly 285 - switch { 286 - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 - w.WriteHeader(http.StatusNotModified) 289 - return 290 - } 291 - w.Header().Set("ETag", eTag) 292 - 293 - case strings.HasPrefix(mimeType, "text/plain"): 294 - w.Header().Set("Cache-Control", "public, no-cache") 295 - 296 - default: 297 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 - return 300 - } 301 - 302 - w.Header().Set("Content-Type", mimeType) 303 - w.Write(contents) 304 - } 305 - 306 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 - treePath := chi.URLParam(r, "*") 308 - ref := chi.URLParam(r, "ref") 309 - ref, _ = url.PathUnescape(ref) 310 - 311 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 - 313 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 - gr, err := git.Open(path, ref) 315 - if err != nil { 316 - notFound(w) 317 - return 318 - } 319 - 320 - var isBinaryFile bool = false 321 - contents, err := gr.FileContent(treePath) 322 - if errors.Is(err, git.ErrBinaryFile) { 323 - isBinaryFile = true 324 - } else if errors.Is(err, object.ErrFileNotFound) { 325 - notFound(w) 326 - return 327 - } else if err != nil { 328 - writeError(w, err.Error(), http.StatusInternalServerError) 329 - return 330 - } 331 - 332 - bytes := []byte(contents) 333 - // safe := string(sanitize(bytes)) 334 - sizeHint := len(bytes) 335 - 336 - resp := types.RepoBlobResponse{ 337 - Ref: ref, 338 - Contents: string(bytes), 339 - Path: treePath, 340 - IsBinary: isBinaryFile, 341 - SizeHint: uint64(sizeHint), 342 - } 343 - 344 - h.showFile(resp, w, l) 345 - } 346 - 347 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 - name := chi.URLParam(r, "name") 349 - file := chi.URLParam(r, "file") 350 - 351 - l := h.l.With("handler", "Archive", "name", name, "file", file) 352 - 353 - // TODO: extend this to add more files compression (e.g.: xz) 354 - if !strings.HasSuffix(file, ".tar.gz") { 355 - notFound(w) 356 - return 357 - } 358 - 359 - ref := strings.TrimSuffix(file, ".tar.gz") 360 - 361 - unescapedRef, err := url.PathUnescape(ref) 362 - if err != nil { 363 - notFound(w) 364 - return 365 - } 366 - 367 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 - 369 - // This allows the browser to use a proper name for the file when 370 - // downloading 371 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 - setContentDisposition(w, filename) 373 - setGZipMIME(w) 374 - 375 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 - gr, err := git.Open(path, unescapedRef) 377 - if err != nil { 378 - notFound(w) 379 - return 380 - } 381 - 382 - gw := gzip.NewWriter(w) 383 - defer gw.Close() 384 - 385 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 - err = gr.WriteTar(gw, prefix) 387 - if err != nil { 388 - // once we start writing to the body we can't report error anymore 389 - // so we are only left with printing the error. 390 - l.Error("writing tar file", "error", err.Error()) 391 - return 392 - } 393 - 394 - err = gw.Flush() 395 - if err != nil { 396 - // once we start writing to the body we can't report error anymore 397 - // so we are only left with printing the error. 398 - l.Error("flushing?", "error", err.Error()) 399 - return 400 - } 401 - } 402 - 403 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 - ref := chi.URLParam(r, "ref") 405 - ref, _ = url.PathUnescape(ref) 406 - 407 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 408 - 409 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 410 - 411 - gr, err := git.Open(path, ref) 412 - if err != nil { 413 - notFound(w) 414 - return 415 - } 416 - 417 - // Get page parameters 418 - page := 1 419 - pageSize := 30 420 - 421 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 - page = p 424 - } 425 - } 426 - 427 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 - pageSize = ps 430 - } 431 - } 432 - 433 - // convert to offset/limit 434 - offset := (page - 1) * pageSize 435 - limit := pageSize 436 - 437 - commits, err := gr.Commits(offset, limit) 438 - if err != nil { 439 - writeError(w, err.Error(), http.StatusInternalServerError) 440 - l.Error("fetching commits", "error", err.Error()) 441 - return 442 - } 443 - 444 - total := len(commits) 445 - 446 - resp := types.RepoLogResponse{ 447 - Commits: commits, 448 - Ref: ref, 449 - Description: getDescription(path), 450 - Log: true, 451 - Total: total, 452 - Page: page, 453 - PerPage: pageSize, 454 - } 455 - 456 - writeJSON(w, resp) 457 - } 458 - 459 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 - ref := chi.URLParam(r, "ref") 461 - ref, _ = url.PathUnescape(ref) 462 - 463 - l := h.l.With("handler", "Diff", "ref", ref) 464 - 465 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 - gr, err := git.Open(path, ref) 467 - if err != nil { 468 - notFound(w) 469 - return 470 - } 471 - 472 - diff, err := gr.Diff() 473 - if err != nil { 474 - writeError(w, err.Error(), http.StatusInternalServerError) 475 - l.Error("getting diff", "error", err.Error()) 476 - return 477 - } 478 - 479 - resp := types.RepoCommitResponse{ 480 - Ref: ref, 481 - Diff: diff, 482 - } 483 - 484 - writeJSON(w, resp) 485 - } 486 - 487 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 - l := h.l.With("handler", "Refs") 490 - 491 - gr, err := git.Open(path, "") 492 - if err != nil { 493 - notFound(w) 494 - return 495 - } 496 - 497 - tags, err := gr.Tags() 498 - if err != nil { 499 - // Non-fatal, we *should* have at least one branch to show. 500 - l.Warn("getting tags", "error", err.Error()) 501 - } 502 - 503 - rtags := []*types.TagReference{} 504 - for _, tag := range tags { 505 - var target *object.Tag 506 - if tag.Target != plumbing.ZeroHash { 507 - target = &tag 508 - } 509 - tr := types.TagReference{ 510 - Tag: target, 511 - } 512 - 513 - tr.Reference = types.Reference{ 514 - Name: tag.Name, 515 - Hash: tag.Hash.String(), 516 - } 517 - 518 - if tag.Message != "" { 519 - tr.Message = tag.Message 520 - } 521 - 522 - rtags = append(rtags, &tr) 523 - } 524 - 525 - resp := types.RepoTagsResponse{ 526 - Tags: rtags, 527 - } 528 - 529 - writeJSON(w, resp) 530 - } 531 - 532 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 - 535 - gr, err := git.PlainOpen(path) 536 - if err != nil { 537 - notFound(w) 538 - return 539 - } 540 - 541 - branches, _ := gr.Branches() 542 - 543 - resp := types.RepoBranchesResponse{ 544 - Branches: branches, 545 - } 546 - 547 - writeJSON(w, resp) 548 - } 549 - 550 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 - branchName := chi.URLParam(r, "branch") 553 - branchName, _ = url.PathUnescape(branchName) 554 - 555 - l := h.l.With("handler", "Branch") 556 - 557 - gr, err := git.PlainOpen(path) 558 - if err != nil { 559 - notFound(w) 560 - return 561 - } 562 - 563 - ref, err := gr.Branch(branchName) 564 - if err != nil { 565 - l.Error("getting branch", "error", err.Error()) 566 - writeError(w, err.Error(), http.StatusInternalServerError) 567 - return 568 - } 569 - 570 - commit, err := gr.Commit(ref.Hash()) 571 - if err != nil { 572 - l.Error("getting commit object", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 - } 576 - 577 - defaultBranch, err := gr.FindMainBranch() 578 - isDefault := false 579 - if err != nil { 580 - l.Error("getting default branch", "error", err.Error()) 581 - // do not quit though 582 - } else if defaultBranch == branchName { 583 - isDefault = true 584 - } 585 - 586 - resp := types.RepoBranchResponse{ 587 - Branch: types.Branch{ 588 - Reference: types.Reference{ 589 - Name: ref.Name().Short(), 590 - Hash: ref.Hash().String(), 591 - }, 592 - Commit: commit, 593 - IsDefault: isDefault, 594 - }, 595 - } 596 - 597 - writeJSON(w, resp) 598 - } 599 - 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 619 - 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 624 - return 625 - } 626 - 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 630 - } 631 - 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 636 - } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 - } 641 - } 642 - 643 - // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 - // l := h.l.With("handler", "RepoForkSync") 645 - // 646 - // data := struct { 647 - // Did string `json:"did"` 648 - // Source string `json:"source"` 649 - // Name string `json:"name,omitempty"` 650 - // HiddenRef string `json:"hiddenref"` 651 - // }{} 652 - // 653 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 - // writeError(w, "invalid request body", http.StatusBadRequest) 655 - // return 656 - // } 657 - // 658 - // did := data.Did 659 - // source := data.Source 660 - // 661 - // if did == "" || source == "" { 662 - // l.Error("invalid request body, empty did or name") 663 - // w.WriteHeader(http.StatusBadRequest) 664 - // return 665 - // } 666 - // 667 - // var name string 668 - // if data.Name != "" { 669 - // name = data.Name 670 - // } else { 671 - // name = filepath.Base(source) 672 - // } 673 - // 674 - // branch := chi.URLParam(r, "branch") 675 - // branch, _ = url.PathUnescape(branch) 676 - // 677 - // relativeRepoPath := filepath.Join(did, name) 678 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 - // 680 - // gr, err := git.PlainOpen(repoPath) 681 - // if err != nil { 682 - // log.Println(err) 683 - // notFound(w) 684 - // return 685 - // } 686 - // 687 - // forkCommit, err := gr.ResolveRevision(branch) 688 - // if err != nil { 689 - // l.Error("error resolving ref revision", "msg", err.Error()) 690 - // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 - // return 692 - // } 693 - // 694 - // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 - // if err != nil { 696 - // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 - // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 - // return 699 - // } 700 - // 701 - // status := types.UpToDate 702 - // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 - // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 - // if err != nil { 705 - // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 - // return 707 - // } 708 - // 709 - // if isAncestor { 710 - // status = types.FastForwardable 711 - // } else { 712 - // status = types.Conflict 713 - // } 714 - // } 715 - // 716 - // w.Header().Set("Content-Type", "application/json") 717 - // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 - // } 719 - 720 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 - ref := chi.URLParam(r, "ref") 723 - ref, _ = url.PathUnescape(ref) 724 - 725 - l := h.l.With("handler", "RepoLanguages") 726 - 727 - gr, err := git.Open(repoPath, ref) 728 - if err != nil { 729 - l.Error("opening repo", "error", err.Error()) 730 - notFound(w) 731 - return 732 - } 733 - 734 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 - defer cancel() 736 - 737 - sizes, err := gr.AnalyzeLanguages(ctx) 738 - if err != nil { 739 - l.Error("failed to analyze languages", "error", err.Error()) 740 - writeError(w, err.Error(), http.StatusNoContent) 741 - return 742 - } 743 - 744 - resp := types.RepoLanguageResponse{Languages: sizes} 745 - 746 - writeJSON(w, resp) 747 - } 748 - 749 - // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 - // l := h.l.With("handler", "RepoForkSync") 751 - // 752 - // data := struct { 753 - // Did string `json:"did"` 754 - // Source string `json:"source"` 755 - // Name string `json:"name,omitempty"` 756 - // }{} 757 - // 758 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 - // writeError(w, "invalid request body", http.StatusBadRequest) 760 - // return 761 - // } 762 - // 763 - // did := data.Did 764 - // source := data.Source 765 - // 766 - // if did == "" || source == "" { 767 - // l.Error("invalid request body, empty did or name") 768 - // w.WriteHeader(http.StatusBadRequest) 769 - // return 770 - // } 771 - // 772 - // var name string 773 - // if data.Name != "" { 774 - // name = data.Name 775 - // } else { 776 - // name = filepath.Base(source) 777 - // } 778 - // 779 - // branch := chi.URLParam(r, "branch") 780 - // branch, _ = url.PathUnescape(branch) 781 - // 782 - // relativeRepoPath := filepath.Join(did, name) 783 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 - // 785 - // gr, err := git.Open(repoPath, branch) 786 - // if err != nil { 787 - // log.Println(err) 788 - // notFound(w) 789 - // return 790 - // } 791 - // 792 - // err = gr.Sync() 793 - // if err != nil { 794 - // l.Error("error syncing repo fork", "error", err.Error()) 795 - // writeError(w, err.Error(), http.StatusInternalServerError) 796 - // return 797 - // } 798 - // 799 - // w.WriteHeader(http.StatusNoContent) 800 - // } 801 - 802 - // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 - // l := h.l.With("handler", "RepoFork") 804 - // 805 - // data := struct { 806 - // Did string `json:"did"` 807 - // Source string `json:"source"` 808 - // Name string `json:"name,omitempty"` 809 - // }{} 810 - // 811 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 - // writeError(w, "invalid request body", http.StatusBadRequest) 813 - // return 814 - // } 815 - // 816 - // did := data.Did 817 - // source := data.Source 818 - // 819 - // if did == "" || source == "" { 820 - // l.Error("invalid request body, empty did or name") 821 - // w.WriteHeader(http.StatusBadRequest) 822 - // return 823 - // } 824 - // 825 - // var name string 826 - // if data.Name != "" { 827 - // name = data.Name 828 - // } else { 829 - // name = filepath.Base(source) 830 - // } 831 - // 832 - // relativeRepoPath := filepath.Join(did, name) 833 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 - // 835 - // err := git.Fork(repoPath, source) 836 - // if err != nil { 837 - // l.Error("forking repo", "error", err.Error()) 838 - // writeError(w, err.Error(), http.StatusInternalServerError) 839 - // return 840 - // } 841 - // 842 - // // add perms for this user to access the repo 843 - // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 - // if err != nil { 845 - // l.Error("adding repo permissions", "error", err.Error()) 846 - // writeError(w, err.Error(), http.StatusInternalServerError) 847 - // return 848 - // } 849 - // 850 - // hook.SetupRepo( 851 - // hook.Config( 852 - // hook.WithScanPath(h.c.Repo.ScanPath), 853 - // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 - // ), 855 - // repoPath, 856 - // ) 857 - // 858 - // w.WriteHeader(http.StatusNoContent) 859 - // } 860 - 861 - // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 - // l := h.l.With("handler", "RemoveRepo") 863 - // 864 - // data := struct { 865 - // Did string `json:"did"` 866 - // Name string `json:"name"` 867 - // }{} 868 - // 869 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 - // writeError(w, "invalid request body", http.StatusBadRequest) 871 - // return 872 - // } 873 - // 874 - // did := data.Did 875 - // name := data.Name 876 - // 877 - // if did == "" || name == "" { 878 - // l.Error("invalid request body, empty did or name") 879 - // w.WriteHeader(http.StatusBadRequest) 880 - // return 881 - // } 882 - // 883 - // relativeRepoPath := filepath.Join(did, name) 884 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 - // err := os.RemoveAll(repoPath) 886 - // if err != nil { 887 - // l.Error("removing repo", "error", err.Error()) 888 - // writeError(w, err.Error(), http.StatusInternalServerError) 889 - // return 890 - // } 891 - // 892 - // w.WriteHeader(http.StatusNoContent) 893 - // 894 - // } 895 - 896 - // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 - // 899 - // data := types.MergeRequest{} 900 - // 901 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 - // writeError(w, err.Error(), http.StatusBadRequest) 903 - // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 - // return 905 - // } 906 - // 907 - // mo := &git.MergeOptions{ 908 - // AuthorName: data.AuthorName, 909 - // AuthorEmail: data.AuthorEmail, 910 - // CommitBody: data.CommitBody, 911 - // CommitMessage: data.CommitMessage, 912 - // } 913 - // 914 - // patch := data.Patch 915 - // branch := data.Branch 916 - // gr, err := git.Open(path, branch) 917 - // if err != nil { 918 - // notFound(w) 919 - // return 920 - // } 921 - // 922 - // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 - // 924 - // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 - // var mergeErr *git.ErrMerge 926 - // if errors.As(err, &mergeErr) { 927 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 - // for i, conflict := range mergeErr.Conflicts { 929 - // conflicts[i] = types.ConflictInfo{ 930 - // Filename: conflict.Filename, 931 - // Reason: conflict.Reason, 932 - // } 933 - // } 934 - // response := types.MergeCheckResponse{ 935 - // IsConflicted: true, 936 - // Conflicts: conflicts, 937 - // Message: mergeErr.Message, 938 - // } 939 - // writeConflict(w, response) 940 - // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 - // } else { 942 - // writeError(w, err.Error(), http.StatusBadRequest) 943 - // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 - // } 945 - // return 946 - // } 947 - // 948 - // w.WriteHeader(http.StatusOK) 949 - // } 950 - 951 - // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 - // 954 - // var data struct { 955 - // Patch string `json:"patch"` 956 - // Branch string `json:"branch"` 957 - // } 958 - // 959 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 - // writeError(w, err.Error(), http.StatusBadRequest) 961 - // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 - // return 963 - // } 964 - // 965 - // patch := data.Patch 966 - // branch := data.Branch 967 - // gr, err := git.Open(path, branch) 968 - // if err != nil { 969 - // notFound(w) 970 - // return 971 - // } 972 - // 973 - // err = gr.MergeCheck([]byte(patch), branch) 974 - // if err == nil { 975 - // response := types.MergeCheckResponse{ 976 - // IsConflicted: false, 977 - // } 978 - // writeJSON(w, response) 979 - // return 980 - // } 981 - // 982 - // var mergeErr *git.ErrMerge 983 - // if errors.As(err, &mergeErr) { 984 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 - // for i, conflict := range mergeErr.Conflicts { 986 - // conflicts[i] = types.ConflictInfo{ 987 - // Filename: conflict.Filename, 988 - // Reason: conflict.Reason, 989 - // } 990 - // } 991 - // response := types.MergeCheckResponse{ 992 - // IsConflicted: true, 993 - // Conflicts: conflicts, 994 - // Message: mergeErr.Message, 995 - // } 996 - // writeConflict(w, response) 997 - // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 - // return 999 - // } 1000 - // writeError(w, err.Error(), http.StatusInternalServerError) 1001 - // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 - // } 1003 - 1004 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 - rev1 := chi.URLParam(r, "rev1") 1006 - rev1, _ = url.PathUnescape(rev1) 1007 - 1008 - rev2 := chi.URLParam(r, "rev2") 1009 - rev2, _ = url.PathUnescape(rev2) 1010 - 1011 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 - 1013 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 - gr, err := git.PlainOpen(path) 1015 - if err != nil { 1016 - notFound(w) 1017 - return 1018 - } 1019 - 1020 - commit1, err := gr.ResolveRevision(rev1) 1021 - if err != nil { 1022 - l.Error("error resolving revision 1", "msg", err.Error()) 1023 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 - return 1025 - } 1026 - 1027 - commit2, err := gr.ResolveRevision(rev2) 1028 - if err != nil { 1029 - l.Error("error resolving revision 2", "msg", err.Error()) 1030 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 - return 1032 - } 1033 - 1034 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 - if err != nil { 1036 - l.Error("error comparing revisions", "msg", err.Error()) 1037 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 - return 1039 - } 1040 - 1041 - writeJSON(w, types.RepoFormatPatchResponse{ 1042 - Rev1: commit1.Hash.String(), 1043 - Rev2: commit2.Hash.String(), 1044 - FormatPatch: formatPatch, 1045 - Patch: rawPatch, 1046 - }) 1047 - } 1048 - 1049 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 - l := h.l.With("handler", "DefaultBranch") 1051 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 - 1053 - gr, err := git.Open(path, "") 1054 - if err != nil { 1055 - notFound(w) 1056 - return 1057 - } 1058 - 1059 - branch, err := gr.FindMainBranch() 1060 - if err != nil { 1061 - writeError(w, err.Error(), http.StatusInternalServerError) 1062 - l.Error("getting default branch", "error", err.Error()) 1063 - return 1064 - } 1065 - 1066 - writeJSON(w, types.RepoDefaultBranchResponse{ 1067 - Branch: branch, 1068 - }) 1069 - }
+15 -10
knotserver/ingester.go
··· 24 24 "tangled.sh/tangled.sh/core/workflow" 25 25 ) 26 26 27 - func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 28 l := log.FromContext(ctx) 29 29 raw := json.RawMessage(event.Commit.Record) 30 30 did := event.Did ··· 46 46 return nil 47 47 } 48 48 49 - func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 50 l := log.FromContext(ctx) 51 51 raw := json.RawMessage(event.Commit.Record) 52 52 did := event.Did ··· 86 86 return nil 87 87 } 88 88 89 - func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 90 raw := json.RawMessage(event.Commit.Record) 91 91 did := event.Did 92 92 ··· 98 98 l := log.FromContext(ctx) 99 99 l = l.With("handler", "processPull") 100 100 l = l.With("did", did) 101 - l = l.With("target_repo", record.TargetRepo) 102 - l = l.With("target_branch", record.TargetBranch) 101 + 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 104 + } 105 + 106 + l = l.With("target_repo", record.Target.Repo) 107 + l = l.With("target_branch", record.Target.Branch) 103 108 104 109 if record.Source == nil { 105 110 return fmt.Errorf("ignoring pull record: not a branch-based pull request") ··· 109 114 return fmt.Errorf("ignoring pull record: fork based pull") 110 115 } 111 116 112 - repoAt, err := syntax.ParseATURI(record.TargetRepo) 117 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 113 118 if err != nil { 114 119 return fmt.Errorf("failed to parse ATURI: %w", err) 115 120 } ··· 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{ ··· 214 219 } 215 220 216 221 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 218 223 raw := json.RawMessage(event.Commit.Record) 219 224 did := event.Did 220 225 ··· 275 280 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 276 281 } 277 282 278 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 279 284 l := log.FromContext(ctx) 280 285 281 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 318 323 return nil 319 324 } 320 325 321 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 322 327 if event.Kind != models.EventKindCommit { 323 328 return nil 324 329 }
+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 + }
-207
knotserver/routes.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 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 - ) 21 - 22 - type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 - resolver *idresolver.Resolver 30 - } 31 - 32 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 - r := chi.NewRouter() 34 - 35 - h := Handle{ 36 - c: c, 37 - db: db, 38 - e: e, 39 - l: l, 40 - jc: jc, 41 - n: n, 42 - resolver: idresolver.DefaultResolver(), 43 - } 44 - 45 - err := e.AddKnot(rbac.ThisServer) 46 - if err != nil { 47 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 48 - } 49 - 50 - // configure owner 51 - if err = h.configureOwner(); err != nil { 52 - return nil, err 53 - } 54 - h.l.Info("owner set", "did", h.c.Server.Owner) 55 - h.jc.AddDid(h.c.Server.Owner) 56 - 57 - // configure known-dids in jetstream consumer 58 - dids, err := h.db.GetAllDids() 59 - if err != nil { 60 - return nil, fmt.Errorf("failed to get all dids: %w", err) 61 - } 62 - for _, d := range dids { 63 - jc.AddDid(d) 64 - } 65 - 66 - err = h.jc.StartJetstream(ctx, h.processMessages) 67 - if err != nil { 68 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 69 - } 70 - 71 - r.Get("/", h.Index) 72 - r.Get("/capabilities", h.Capabilities) 73 - r.Get("/version", h.Version) 74 - r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 - w.Write([]byte(h.c.Server.Owner)) 76 - }) 77 - r.Route("/{did}", func(r chi.Router) { 78 - // Repo routes 79 - r.Route("/{name}", func(r chi.Router) { 80 - 81 - r.Route("/languages", func(r chi.Router) { 82 - r.Get("/", h.RepoLanguages) 83 - r.Get("/{ref}", h.RepoLanguages) 84 - }) 85 - 86 - r.Get("/", h.RepoIndex) 87 - r.Get("/info/refs", h.InfoRefs) 88 - r.Post("/git-upload-pack", h.UploadPack) 89 - r.Post("/git-receive-pack", h.ReceivePack) 90 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 91 - 92 - r.Route("/tree/{ref}", func(r chi.Router) { 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/*", h.RepoTree) 95 - }) 96 - 97 - r.Route("/blob/{ref}", func(r chi.Router) { 98 - r.Get("/*", h.Blob) 99 - }) 100 - 101 - r.Route("/raw/{ref}", func(r chi.Router) { 102 - r.Get("/*", h.BlobRaw) 103 - }) 104 - 105 - r.Get("/log/{ref}", h.Log) 106 - r.Get("/archive/{file}", h.Archive) 107 - r.Get("/commit/{ref}", h.Diff) 108 - r.Get("/tags", h.Tags) 109 - r.Route("/branches", func(r chi.Router) { 110 - r.Get("/", h.Branches) 111 - r.Get("/{branch}", h.Branch) 112 - r.Get("/default", h.DefaultBranch) 113 - }) 114 - }) 115 - }) 116 - 117 - // xrpc apis 118 - r.Mount("/xrpc", h.XrpcRouter()) 119 - 120 - // Socket that streams git oplogs 121 - r.Get("/events", h.Events) 122 - 123 - // All public keys on the knot. 124 - r.Get("/keys", h.Keys) 125 - 126 - return r, nil 127 - } 128 - 129 - func (h *Handle) XrpcRouter() http.Handler { 130 - logger := tlog.New("knots") 131 - 132 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 133 - 134 - xrpc := &xrpc.Xrpc{ 135 - Config: h.c, 136 - Db: h.db, 137 - Ingester: h.jc, 138 - Enforcer: h.e, 139 - Logger: logger, 140 - Notifier: h.n, 141 - Resolver: h.resolver, 142 - ServiceAuth: serviceAuth, 143 - } 144 - return xrpc.Router() 145 - } 146 - 147 - // version is set during build time. 148 - var version string 149 - 150 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 - if version == "" { 152 - info, ok := debug.ReadBuildInfo() 153 - if !ok { 154 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 155 - return 156 - } 157 - 158 - var modVer string 159 - for _, mod := range info.Deps { 160 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 - version = mod.Version 162 - break 163 - } 164 - } 165 - 166 - if modVer == "" { 167 - version = "unknown" 168 - } 169 - } 170 - 171 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 - fmt.Fprintf(w, "knotserver/%s", version) 173 - } 174 - 175 - func (h *Handle) configureOwner() error { 176 - cfgOwner := h.c.Server.Owner 177 - 178 - rbacDomain := "thisserver" 179 - 180 - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 181 - if err != nil { 182 - return err 183 - } 184 - 185 - switch len(existing) { 186 - case 0: 187 - // no owner configured, continue 188 - case 1: 189 - // find existing owner 190 - existingOwner := existing[0] 191 - 192 - // no ownership change, this is okay 193 - if existingOwner == h.c.Server.Owner { 194 - break 195 - } 196 - 197 - // remove existing owner 198 - err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 199 - if err != nil { 200 - return nil 201 - } 202 - default: 203 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 204 - } 205 - 206 - return h.e.AddKnotOwner(rbacDomain, cfgOwner) 207 - }
+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
+58
knotserver/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 13 + cursor := r.URL.Query().Get("cursor") 14 + 15 + limit := 100 // default 16 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 17 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 18 + limit = l 19 + } 20 + } 21 + 22 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 23 + if err != nil { 24 + x.Logger.Error("failed to get public keys", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to retrieve public keys"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 33 + for _, key := range keys { 34 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 35 + Did: key.Did, 36 + Key: key.Key, 37 + CreatedAt: key.CreatedAt, 38 + }) 39 + } 40 + 41 + response := tangled.KnotListKeys_Output{ 42 + Keys: publicKeys, 43 + } 44 + 45 + if nextCursor != "" { 46 + response.Cursor = &nextCursor 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + if err := json.NewEncoder(w).Encode(response); err != nil { 51 + x.Logger.Error("failed to encode response", "error", err) 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("InternalServerError"), 54 + xrpcerr.WithMessage("failed to encode response"), 55 + ), http.StatusInternalServerError) 56 + return 57 + } 58 + }
+3 -1
knotserver/xrpc/merge.go
··· 67 67 return 68 68 } 69 69 70 - mo := &git.MergeOptions{} 70 + mo := git.MergeOptions{} 71 71 if data.AuthorName != nil { 72 72 mo.AuthorName = *data.AuthorName 73 73 } ··· 81 81 mo.CommitMessage = *data.CommitMessage 82 82 } 83 83 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 84 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 87 86 88 err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+31
knotserver/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+80
knotserver/xrpc/repo_archive.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo, repoPath, unescapedRef, err := x.parseStandardParams(r) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + format := r.URL.Query().Get("format") 23 + if format == "" { 24 + format = "tar.gz" // default 25 + } 26 + 27 + prefix := r.URL.Query().Get("prefix") 28 + 29 + if format != "tar.gz" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("only tar.gz format is supported"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + gr, err := git.Open(repoPath, unescapedRef) 38 + if err != nil { 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("RefNotFound"), 41 + xrpcerr.WithMessage("repository or ref not found"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + repoParts := strings.Split(repo, "/") 47 + repoName := repoParts[len(repoParts)-1] 48 + 49 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 50 + 51 + var archivePrefix string 52 + if prefix != "" { 53 + archivePrefix = prefix 54 + } else { 55 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 56 + } 57 + 58 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 59 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 60 + w.Header().Set("Content-Type", "application/gzip") 61 + 62 + gw := gzip.NewWriter(w) 63 + defer gw.Close() 64 + 65 + err = gr.WriteTar(gw, archivePrefix) 66 + if err != nil { 67 + // once we start writing to the body we can't report error anymore 68 + // so we are only left with logging the error 69 + x.Logger.Error("writing tar file", "error", err.Error()) 70 + return 71 + } 72 + 73 + err = gw.Flush() 74 + if err != nil { 75 + // once we start writing to the body we can't report error anymore 76 + // so we are only left with logging the error 77 + x.Logger.Error("flushing", "error", err.Error()) 78 + return 79 + } 80 + }
+151
knotserver/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/knotserver/git" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 19 + _, repoPath, ref, err := x.parseStandardParams(r) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + treePath := r.URL.Query().Get("path") 26 + if treePath == "" { 27 + writeError(w, xrpcerr.NewXrpcError( 28 + xrpcerr.WithTag("InvalidRequest"), 29 + xrpcerr.WithMessage("missing path parameter"), 30 + ), http.StatusBadRequest) 31 + return 32 + } 33 + 34 + raw := r.URL.Query().Get("raw") == "true" 35 + 36 + gr, err := git.Open(repoPath, ref) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("RefNotFound"), 40 + xrpcerr.WithMessage("repository or ref not found"), 41 + ), http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + 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 + w.Header().Set("Content-Type", "application/json") 127 + if err := json.NewEncoder(w).Encode(response); err != nil { 128 + x.Logger.Error("failed to encode response", "error", err) 129 + writeError(w, xrpcerr.NewXrpcError( 130 + xrpcerr.WithTag("InternalServerError"), 131 + xrpcerr.WithMessage("failed to encode response"), 132 + ), http.StatusInternalServerError) 133 + return 134 + } 135 + } 136 + 137 + // isTextualMimeType returns true if the MIME type represents textual content 138 + // that should be served as text/plain for security reasons 139 + func isTextualMimeType(mimeType string) bool { 140 + textualTypes := []string{ 141 + "application/json", 142 + "application/xml", 143 + "application/yaml", 144 + "application/x-yaml", 145 + "application/toml", 146 + "application/javascript", 147 + "application/ecmascript", 148 + } 149 + 150 + return slices.Contains(textualTypes, mimeType) 151 + }
+96
knotserver/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RepoNotFound"), 36 + xrpcerr.WithMessage("repository not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + ref, err := gr.Branch(branchName) 42 + if err != nil { 43 + x.Logger.Error("getting branch", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("BranchNotFound"), 46 + xrpcerr.WithMessage("branch not found"), 47 + ), http.StatusNotFound) 48 + return 49 + } 50 + 51 + commit, err := gr.Commit(ref.Hash()) 52 + if err != nil { 53 + x.Logger.Error("getting commit object", "error", err.Error()) 54 + writeError(w, xrpcerr.NewXrpcError( 55 + xrpcerr.WithTag("BranchNotFound"), 56 + xrpcerr.WithMessage("failed to get commit object"), 57 + ), http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + defaultBranch, err := gr.FindMainBranch() 62 + isDefault := false 63 + if err != nil { 64 + x.Logger.Error("getting default branch", "error", err.Error()) 65 + } else if defaultBranch == branchName { 66 + isDefault = true 67 + } 68 + 69 + response := tangled.RepoBranch_Output{ 70 + Name: ref.Name().Short(), 71 + Hash: ref.Hash().String(), 72 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 73 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 74 + IsDefault: &isDefault, 75 + } 76 + 77 + if commit.Message != "" { 78 + response.Message = &commit.Message 79 + } 80 + 81 + response.Author = &tangled.RepoBranch_Signature{ 82 + Name: commit.Author.Name, 83 + Email: commit.Author.Email, 84 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + if err := json.NewEncoder(w).Encode(response); err != nil { 89 + x.Logger.Error("failed to encode response", "error", err) 90 + writeError(w, xrpcerr.NewXrpcError( 91 + xrpcerr.WithTag("InternalServerError"), 92 + xrpcerr.WithMessage("failed to encode response"), 93 + ), http.StatusInternalServerError) 94 + return 95 + } 96 + }
+72
knotserver/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + cursor := r.URL.Query().Get("cursor") 22 + 23 + // limit := 50 // default 24 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 25 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 26 + // limit = l 27 + // } 28 + // } 29 + 30 + limit := 500 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RepoNotFound"), 36 + xrpcerr.WithMessage("repository not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + branches, _ := gr.Branches() 42 + 43 + offset := 0 44 + if cursor != "" { 45 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 46 + offset = o 47 + } 48 + } 49 + 50 + end := offset + limit 51 + if end > len(branches) { 52 + end = len(branches) 53 + } 54 + 55 + paginatedBranches := branches[offset:end] 56 + 57 + // Create response using existing types.RepoBranchesResponse 58 + response := types.RepoBranchesResponse{ 59 + Branches: paginatedBranches, 60 + } 61 + 62 + // Write JSON response directly 63 + w.Header().Set("Content-Type", "application/json") 64 + if err := json.NewEncoder(w).Encode(response); err != nil { 65 + x.Logger.Error("failed to encode response", "error", err) 66 + writeError(w, xrpcerr.NewXrpcError( 67 + xrpcerr.WithTag("InternalServerError"), 68 + xrpcerr.WithMessage("failed to encode response"), 69 + ), http.StatusInternalServerError) 70 + return 71 + } 72 + }
+98
knotserver/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + rev1Param := r.URL.Query().Get("rev1") 23 + if rev1Param == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing rev1 parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + rev2Param := r.URL.Query().Get("rev2") 32 + if rev2Param == "" { 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("InvalidRequest"), 35 + xrpcerr.WithMessage("missing rev2 parameter"), 36 + ), http.StatusBadRequest) 37 + return 38 + } 39 + 40 + rev1, _ := url.PathUnescape(rev1Param) 41 + rev2, _ := url.PathUnescape(rev2Param) 42 + 43 + gr, err := git.PlainOpen(repoPath) 44 + if err != nil { 45 + writeError(w, xrpcerr.NewXrpcError( 46 + xrpcerr.WithTag("RepoNotFound"), 47 + xrpcerr.WithMessage("repository not found"), 48 + ), http.StatusNotFound) 49 + return 50 + } 51 + 52 + commit1, err := gr.ResolveRevision(rev1) 53 + if err != nil { 54 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 55 + writeError(w, xrpcerr.NewXrpcError( 56 + xrpcerr.WithTag("RevisionNotFound"), 57 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 58 + ), http.StatusBadRequest) 59 + return 60 + } 61 + 62 + commit2, err := gr.ResolveRevision(rev2) 63 + if err != nil { 64 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 65 + writeError(w, xrpcerr.NewXrpcError( 66 + xrpcerr.WithTag("RevisionNotFound"), 67 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 68 + ), http.StatusBadRequest) 69 + return 70 + } 71 + 72 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 73 + if err != nil { 74 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 75 + writeError(w, xrpcerr.NewXrpcError( 76 + xrpcerr.WithTag("CompareError"), 77 + xrpcerr.WithMessage("error comparing revisions"), 78 + ), http.StatusBadRequest) 79 + return 80 + } 81 + 82 + resp := types.RepoFormatPatchResponse{ 83 + Rev1: commit1.Hash.String(), 84 + Rev2: commit2.Hash.String(), 85 + FormatPatch: formatPatch, 86 + Patch: rawPatch, 87 + } 88 + 89 + w.Header().Set("Content-Type", "application/json") 90 + if err := json.NewEncoder(w).Encode(resp); err != nil { 91 + x.Logger.Error("failed to encode response", "error", err) 92 + writeError(w, xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InternalServerError"), 94 + xrpcerr.WithMessage("failed to encode response"), 95 + ), http.StatusInternalServerError) 96 + return 97 + } 98 + }
+65
knotserver/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + refParam := r.URL.Query().Get("ref") 22 + if refParam == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing ref parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + ref, _ := url.QueryUnescape(refParam) 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RefNotFound"), 36 + xrpcerr.WithMessage("repository or ref not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + diff, err := gr.Diff() 42 + if err != nil { 43 + x.Logger.Error("getting diff", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("RefNotFound"), 46 + xrpcerr.WithMessage("failed to generate diff"), 47 + ), http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + resp := types.RepoCommitResponse{ 52 + Ref: ref, 53 + Diff: diff, 54 + } 55 + 56 + w.Header().Set("Content-Type", "application/json") 57 + if err := json.NewEncoder(w).Encode(resp); err != nil { 58 + x.Logger.Error("failed to encode response", "error", err) 59 + writeError(w, xrpcerr.NewXrpcError( 60 + xrpcerr.WithTag("InternalServerError"), 61 + xrpcerr.WithMessage("failed to encode response"), 62 + ), http.StatusInternalServerError) 63 + return 64 + } 65 + }
+54
knotserver/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.Open(repoPath, "") 21 + if err != nil { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("RepoNotFound"), 24 + xrpcerr.WithMessage("repository not found"), 25 + ), http.StatusNotFound) 26 + return 27 + } 28 + 29 + branch, err := gr.FindMainBranch() 30 + if err != nil { 31 + x.Logger.Error("getting default branch", "error", err.Error()) 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("failed to get default branch"), 35 + ), http.StatusInternalServerError) 36 + return 37 + } 38 + 39 + response := tangled.RepoGetDefaultBranch_Output{ 40 + Name: branch, 41 + Hash: "", 42 + When: "1970-01-01T00:00:00.000Z", 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + if err := json.NewEncoder(w).Encode(response); err != nil { 47 + x.Logger.Error("failed to encode response", "error", err) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("InternalServerError"), 50 + xrpcerr.WithMessage("failed to encode response"), 51 + ), http.StatusInternalServerError) 52 + return 53 + } 54 + }
+93
knotserver/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "math" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 17 + refParam := r.URL.Query().Get("ref") 18 + if refParam == "" { 19 + refParam = "HEAD" // default 20 + } 21 + ref, _ := url.PathUnescape(refParam) 22 + 23 + repo := r.URL.Query().Get("repo") 24 + repoPath, err := x.parseRepoParam(repo) 25 + if err != nil { 26 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + gr, err := git.Open(repoPath, ref) 31 + if err != nil { 32 + x.Logger.Error("opening repo", "error", err.Error()) 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("RefNotFound"), 35 + xrpcerr.WithMessage("repository or ref not found"), 36 + ), http.StatusNotFound) 37 + return 38 + } 39 + 40 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 41 + defer cancel() 42 + 43 + sizes, err := gr.AnalyzeLanguages(ctx) 44 + if err != nil { 45 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 46 + writeError(w, xrpcerr.NewXrpcError( 47 + xrpcerr.WithTag("InvalidRequest"), 48 + xrpcerr.WithMessage("failed to analyze repository languages"), 49 + ), http.StatusNoContent) 50 + return 51 + } 52 + 53 + var apiLanguages []*tangled.RepoLanguages_Language 54 + var totalSize int64 55 + 56 + for _, size := range sizes { 57 + totalSize += size 58 + } 59 + 60 + for name, size := range sizes { 61 + percentagef64 := float64(size) / float64(totalSize) * 100 62 + percentage := math.Round(percentagef64) 63 + 64 + lang := &tangled.RepoLanguages_Language{ 65 + Name: name, 66 + Size: size, 67 + Percentage: int64(percentage), 68 + } 69 + 70 + apiLanguages = append(apiLanguages, lang) 71 + } 72 + 73 + response := tangled.RepoLanguages_Output{ 74 + Ref: ref, 75 + Languages: apiLanguages, 76 + } 77 + 78 + if totalSize > 0 { 79 + response.TotalSize = &totalSize 80 + totalFiles := int64(len(sizes)) 81 + response.TotalFiles = &totalFiles 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + if err := json.NewEncoder(w).Encode(response); err != nil { 86 + x.Logger.Error("failed to encode response", "error", err) 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("InternalServerError"), 89 + xrpcerr.WithMessage("failed to encode response"), 90 + ), http.StatusInternalServerError) 91 + return 92 + } 93 + }
+111
knotserver/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "strconv" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + refParam := r.URL.Query().Get("ref") 23 + if refParam == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing ref parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + path := r.URL.Query().Get("path") 32 + cursor := r.URL.Query().Get("cursor") 33 + 34 + limit := 50 // default 35 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 36 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 37 + limit = l 38 + } 39 + } 40 + 41 + ref, err := url.QueryUnescape(refParam) 42 + if err != nil { 43 + writeError(w, xrpcerr.NewXrpcError( 44 + xrpcerr.WithTag("InvalidRequest"), 45 + xrpcerr.WithMessage("invalid ref parameter"), 46 + ), http.StatusBadRequest) 47 + return 48 + } 49 + 50 + gr, err := git.Open(repoPath, ref) 51 + if err != nil { 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("RefNotFound"), 54 + xrpcerr.WithMessage("repository or ref not found"), 55 + ), http.StatusNotFound) 56 + return 57 + } 58 + 59 + offset := 0 60 + if cursor != "" { 61 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 62 + offset = o 63 + } 64 + } 65 + 66 + commits, err := gr.Commits(offset, limit) 67 + if err != nil { 68 + x.Logger.Error("fetching commits", "error", err.Error()) 69 + writeError(w, xrpcerr.NewXrpcError( 70 + xrpcerr.WithTag("PathNotFound"), 71 + xrpcerr.WithMessage("failed to read commit log"), 72 + ), http.StatusNotFound) 73 + return 74 + } 75 + 76 + total, err := gr.TotalCommits() 77 + if err != nil { 78 + x.Logger.Error("fetching total commits", "error", err.Error()) 79 + writeError(w, xrpcerr.NewXrpcError( 80 + xrpcerr.WithTag("InternalServerError"), 81 + xrpcerr.WithMessage("failed to fetch total commits"), 82 + ), http.StatusNotFound) 83 + return 84 + } 85 + 86 + // Create response using existing types.RepoLogResponse 87 + response := types.RepoLogResponse{ 88 + Commits: commits, 89 + Ref: ref, 90 + Page: (offset / limit) + 1, 91 + PerPage: limit, 92 + Total: total, 93 + } 94 + 95 + if path != "" { 96 + response.Description = path 97 + } 98 + 99 + response.Log = true 100 + 101 + // Write JSON response directly 102 + w.Header().Set("Content-Type", "application/json") 103 + if err := json.NewEncoder(w).Encode(response); err != nil { 104 + x.Logger.Error("failed to encode response", "error", err) 105 + writeError(w, xrpcerr.NewXrpcError( 106 + xrpcerr.WithTag("InternalServerError"), 107 + xrpcerr.WithMessage("failed to encode response"), 108 + ), http.StatusInternalServerError) 109 + return 110 + } 111 + }
+99
knotserver/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + "tangled.sh/tangled.sh/core/types" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + cursor := r.URL.Query().Get("cursor") 25 + 26 + limit := 50 // default 27 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 28 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 29 + limit = l 30 + } 31 + } 32 + 33 + gr, err := git.Open(repoPath, "") 34 + if err != nil { 35 + x.Logger.Error("failed to open", "error", err) 36 + writeError(w, xrpcerr.NewXrpcError( 37 + xrpcerr.WithTag("RepoNotFound"), 38 + xrpcerr.WithMessage("repository not found"), 39 + ), http.StatusNotFound) 40 + return 41 + } 42 + 43 + tags, err := gr.Tags() 44 + if err != nil { 45 + x.Logger.Warn("getting tags", "error", err.Error()) 46 + tags = []object.Tag{} 47 + } 48 + 49 + rtags := []*types.TagReference{} 50 + for _, tag := range tags { 51 + var target *object.Tag 52 + if tag.Target != plumbing.ZeroHash { 53 + target = &tag 54 + } 55 + tr := types.TagReference{ 56 + Tag: target, 57 + } 58 + 59 + tr.Reference = types.Reference{ 60 + Name: tag.Name, 61 + Hash: tag.Hash.String(), 62 + } 63 + 64 + if tag.Message != "" { 65 + tr.Message = tag.Message 66 + } 67 + 68 + rtags = append(rtags, &tr) 69 + } 70 + 71 + // apply pagination manually 72 + offset := 0 73 + if cursor != "" { 74 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 75 + offset = o 76 + } 77 + } 78 + 79 + // calculate end index 80 + end := min(offset+limit, len(rtags)) 81 + 82 + paginatedTags := rtags[offset:end] 83 + 84 + // Create response using existing types.RepoTagsResponse 85 + response := types.RepoTagsResponse{ 86 + Tags: paginatedTags, 87 + } 88 + 89 + // Write JSON response directly 90 + w.Header().Set("Content-Type", "application/json") 91 + if err := json.NewEncoder(w).Encode(response); err != nil { 92 + x.Logger.Error("failed to encode response", "error", err) 93 + writeError(w, xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InternalServerError"), 95 + xrpcerr.WithMessage("failed to encode response"), 96 + ), http.StatusInternalServerError) 97 + return 98 + } 99 + }
+116
knotserver/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "path/filepath" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 15 + ctx := r.Context() 16 + 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + refParam := r.URL.Query().Get("ref") 25 + if refParam == "" { 26 + writeError(w, xrpcerr.NewXrpcError( 27 + xrpcerr.WithTag("InvalidRequest"), 28 + xrpcerr.WithMessage("missing ref parameter"), 29 + ), http.StatusBadRequest) 30 + return 31 + } 32 + 33 + path := r.URL.Query().Get("path") 34 + // path can be empty (defaults to root) 35 + 36 + ref, err := url.QueryUnescape(refParam) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("InvalidRequest"), 40 + xrpcerr.WithMessage("invalid ref parameter"), 41 + ), http.StatusBadRequest) 42 + return 43 + } 44 + 45 + gr, err := git.Open(repoPath, ref) 46 + if err != nil { 47 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("RefNotFound"), 50 + xrpcerr.WithMessage("repository or ref not found"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + files, err := gr.FileTree(ctx, path) 56 + if err != nil { 57 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("PathNotFound"), 60 + xrpcerr.WithMessage("failed to read repository tree"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // convert NiceTree -> tangled.RepoTree_TreeEntry 66 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 67 + for i, file := range files { 68 + entry := &tangled.RepoTree_TreeEntry{ 69 + Name: file.Name, 70 + Mode: file.Mode, 71 + Size: file.Size, 72 + Is_file: file.IsFile, 73 + Is_subtree: file.IsSubtree, 74 + } 75 + 76 + if file.LastCommit != nil { 77 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 78 + Hash: file.LastCommit.Hash.String(), 79 + Message: file.LastCommit.Message, 80 + When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"), 81 + } 82 + } 83 + 84 + treeEntries[i] = entry 85 + } 86 + 87 + var parentPtr *string 88 + if path != "" { 89 + parentPtr = &path 90 + } 91 + 92 + var dotdotPtr *string 93 + if path != "" { 94 + dotdot := filepath.Dir(path) 95 + if dotdot != "." { 96 + dotdotPtr = &dotdot 97 + } 98 + } 99 + 100 + response := tangled.RepoTree_Output{ 101 + Ref: ref, 102 + Parent: parentPtr, 103 + Dotdot: dotdotPtr, 104 + Files: treeEntries, 105 + } 106 + 107 + w.Header().Set("Content-Type", "application/json") 108 + if err := json.NewEncoder(w).Encode(response); err != nil { 109 + x.Logger.Error("failed to encode response", "error", err) 110 + writeError(w, xrpcerr.NewXrpcError( 111 + xrpcerr.WithTag("InternalServerError"), 112 + xrpcerr.WithMessage("failed to encode response"), 113 + ), http.StatusInternalServerError) 114 + return 115 + } 116 + }
+70
knotserver/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "runtime/debug" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + // version is set during build time. 14 + var version string 15 + 16 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 17 + if version == "" { 18 + info, ok := debug.ReadBuildInfo() 19 + if !ok { 20 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 21 + return 22 + } 23 + 24 + var modVer string 25 + var sha string 26 + var modified bool 27 + 28 + for _, mod := range info.Deps { 29 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 30 + modVer = mod.Version 31 + break 32 + } 33 + } 34 + 35 + for _, setting := range info.Settings { 36 + switch setting.Key { 37 + case "vcs.revision": 38 + sha = setting.Value 39 + case "vcs.modified": 40 + modified = setting.Value == "true" 41 + } 42 + } 43 + 44 + if modVer == "" { 45 + modVer = "unknown" 46 + } 47 + 48 + if sha == "" { 49 + version = modVer 50 + } else if modified { 51 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 52 + } else { 53 + version = fmt.Sprintf("%s (%s)", modVer, sha) 54 + } 55 + } 56 + 57 + response := tangled.KnotVersion_Output{ 58 + Version: version, 59 + } 60 + 61 + w.Header().Set("Content-Type", "application/json") 62 + if err := json.NewEncoder(w).Encode(response); err != nil { 63 + x.Logger.Error("failed to encode response", "error", err) 64 + writeError(w, xrpcerr.NewXrpcError( 65 + xrpcerr.WithTag("InternalServerError"), 66 + xrpcerr.WithMessage("failed to encode response"), 67 + ), http.StatusInternalServerError) 68 + return 69 + } 70 + }
+88
knotserver/xrpc/xrpc.go
··· 4 4 "encoding/json" 5 5 "log/slog" 6 6 "net/http" 7 + "net/url" 8 + "strings" 7 9 10 + securejoin "github.com/cyphar/filepath-securejoin" 8 11 "tangled.sh/tangled.sh/core/api/tangled" 9 12 "tangled.sh/tangled.sh/core/idresolver" 10 13 "tangled.sh/tangled.sh/core/jetstream" ··· 50 53 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 54 // - use ETags on clients to keep requests to a minimum 52 55 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 56 + 57 + // repo query endpoints (no auth required) 58 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 65 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 66 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 67 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 68 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 69 + 70 + // knot query endpoints (no auth required) 71 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 72 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 73 + 74 + // service query endpoints (no auth required) 75 + r.Get("/"+tangled.OwnerNSID, x.Owner) 76 + 53 77 return r 78 + } 79 + 80 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 81 + // the full repository path on disk 82 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 83 + if repo == "" { 84 + return "", xrpcerr.NewXrpcError( 85 + xrpcerr.WithTag("InvalidRequest"), 86 + xrpcerr.WithMessage("missing repo parameter"), 87 + ) 88 + } 89 + 90 + // Parse repo string (did/repoName format) 91 + parts := strings.Split(repo, "/") 92 + if len(parts) < 2 { 93 + return "", xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InvalidRequest"), 95 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 96 + ) 97 + } 98 + 99 + did := strings.Join(parts[:len(parts)-1], "/") 100 + repoName := parts[len(parts)-1] 101 + 102 + // Construct repository path using the same logic as didPath 103 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 104 + if err != nil { 105 + return "", xrpcerr.NewXrpcError( 106 + xrpcerr.WithTag("RepoNotFound"), 107 + xrpcerr.WithMessage("failed to access repository"), 108 + ) 109 + } 110 + 111 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 112 + if err != nil { 113 + return "", xrpcerr.NewXrpcError( 114 + xrpcerr.WithTag("RepoNotFound"), 115 + xrpcerr.WithMessage("failed to access repository"), 116 + ) 117 + } 118 + 119 + return repoPath, nil 120 + } 121 + 122 + // parseStandardParams parses common query parameters used by most handlers 123 + func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) { 124 + // Parse repo parameter 125 + repo = r.URL.Query().Get("repo") 126 + repoPath, err = x.parseRepoParam(repo) 127 + if err != nil { 128 + return "", "", "", err 129 + } 130 + 131 + // Parse and unescape ref parameter 132 + refParam := r.URL.Query().Get("ref") 133 + if refParam == "" { 134 + return "", "", "", xrpcerr.NewXrpcError( 135 + xrpcerr.WithTag("InvalidRequest"), 136 + xrpcerr.WithMessage("missing ref parameter"), 137 + ) 138 + } 139 + 140 + ref, _ = url.QueryUnescape(refParam) 141 + return repo, repoPath, ref, nil 54 142 } 55 143 56 144 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+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"
+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 + }
-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 + }
+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 + }
+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 + }
+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 + }
-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 + }
+8 -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.15" 430 - hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 431 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 432 438 version = "v2.0.0-20230729083705-37449abec8cc" 433 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+16
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}"
+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 + }
+2
nix/vm.nix
··· 89 89 hostname = "localhost:6555"; 90 90 listenAddr = "0.0.0.0:6555"; 91 91 dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 92 94 secrets = { 93 95 provider = "sqlite"; 94 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
+2
spindle/config/config.go
··· 17 17 Owner string `env:"OWNER, required"` 18 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 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 20 22 } 21 23 22 24 func (s Server) Did() syntax.DID {
+2 -4
spindle/server.go
··· 100 100 return err 101 101 } 102 102 103 - 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) 104 105 105 106 collections := []string{ 106 107 tangled.SpindleMemberNSID, ··· 202 203 w.Write(motd) 203 204 }) 204 205 mux.HandleFunc("/events", s.Events) 205 - mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 206 - w.Write([]byte(s.cfg.Server.Owner)) 207 - }) 208 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 209 207 210 208 mux.Mount("/xrpc", s.XrpcRouter())
+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 -3
spindle/xrpc/xrpc.go
··· 35 35 func (x *Xrpc) Router() http.Handler { 36 36 r := chi.NewRouter() 37 37 38 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 - r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 40 + 41 + r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 + r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 + r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 44 + }) 45 + 46 + // service query endpoints (no auth required) 47 + r.Get("/"+tangled.OwnerNSID, x.Owner) 41 48 42 49 return r 43 50 }
+5
xrpc/errors/errors.go
··· 51 51 WithMessage("actor DID not supplied"), 52 52 ) 53 53 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 54 59 var AuthError = func(err error) XrpcError { 55 60 return NewXrpcError( 56 61 WithTag("Auth"),