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

Compare changes

Choose any two refs to compare.

Changed files
+11389 -8968
.air
.tangled
.zed
api
appview
cache
session
config
db
issues
knots
middleware
oauth
pages
markup
repoinfo
templates
pulls
repo
reporesolver
serververify
settings
spindles
spindleverify
state
strings
xrpcclient
cmd
genjwks
punchcardPopulate
docs
eventconsumer
cursor
jetstream
knotclient
knotserver
lexicons
log
nix
rbac
spindle
workflow
xrpc
errors
serviceauth
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+3
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+3 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master", "ci"] 3 + branch: ["master"] 4 + 5 + engine: nixery 4 6 5 7 dependencies: 6 8 nixpkgs:
+4 -13
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master", "ci"] 3 + branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 8 + - name: "Check formatting" 12 9 command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 16 - command: | 17 - unformatted=$(gofmt -l .) 18 - test -z "$unformatted" || (echo "$unformatted" && exit 1) 19 - 10 + nix run .#fmt -- --ci
+3 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master", "ci"] 3 + branch: ["master"] 4 + 5 + engine: nixery 4 6 5 7 dependencies: 6 8 nixpkgs:
-16
.zed/settings.json
··· 1 - // Folder-specific settings 2 - // 3 - // For a full list of overridable settings, and general information on folder-specific settings, 4 - // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 - { 6 - "languages": { 7 - "HTML": { 8 - "prettier": { 9 - "format_on_save": false, 10 - "allowed": true, 11 - "parser": "go-template", 12 - "plugins": ["prettier-plugin-go-template"] 13 - } 14 - } 15 - } 16 - }
+556 -1260
api/tangled/cbor_gen.go
··· 1202 1202 1203 1203 return nil 1204 1204 } 1205 - func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error { 1206 - if t == nil { 1207 - _, err := w.Write(cbg.CborNull) 1208 - return err 1209 - } 1210 - 1211 - cw := cbg.NewCborWriter(w) 1212 - fieldCount := 3 1213 - 1214 - if t.LangBreakdown == nil { 1215 - fieldCount-- 1216 - } 1217 - 1218 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1219 - return err 1220 - } 1221 - 1222 - // t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct) 1223 - if len("commitCount") > 1000000 { 1224 - return xerrors.Errorf("Value in field \"commitCount\" was too long") 1225 - } 1226 - 1227 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil { 1228 - return err 1229 - } 1230 - if _, err := cw.WriteString(string("commitCount")); err != nil { 1231 - return err 1232 - } 1233 - 1234 - if err := t.CommitCount.MarshalCBOR(cw); err != nil { 1235 - return err 1236 - } 1237 - 1238 - // t.IsDefaultRef (bool) (bool) 1239 - if len("isDefaultRef") > 1000000 { 1240 - return xerrors.Errorf("Value in field \"isDefaultRef\" was too long") 1241 - } 1242 - 1243 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil { 1244 - return err 1245 - } 1246 - if _, err := cw.WriteString(string("isDefaultRef")); err != nil { 1247 - return err 1248 - } 1249 - 1250 - if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1251 - return err 1252 - } 1253 - 1254 - // t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct) 1255 - if t.LangBreakdown != nil { 1256 - 1257 - if len("langBreakdown") > 1000000 { 1258 - return xerrors.Errorf("Value in field \"langBreakdown\" was too long") 1259 - } 1260 - 1261 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil { 1262 - return err 1263 - } 1264 - if _, err := cw.WriteString(string("langBreakdown")); err != nil { 1265 - return err 1266 - } 1267 - 1268 - if err := t.LangBreakdown.MarshalCBOR(cw); err != nil { 1269 - return err 1270 - } 1271 - } 1272 - return nil 1273 - } 1274 - 1275 - func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) { 1276 - *t = GitRefUpdate_Meta{} 1277 - 1278 - cr := cbg.NewCborReader(r) 1279 - 1280 - maj, extra, err := cr.ReadHeader() 1281 - if err != nil { 1282 - return err 1283 - } 1284 - defer func() { 1285 - if err == io.EOF { 1286 - err = io.ErrUnexpectedEOF 1287 - } 1288 - }() 1289 - 1290 - if maj != cbg.MajMap { 1291 - return fmt.Errorf("cbor input should be of type map") 1292 - } 1293 - 1294 - if extra > cbg.MaxLength { 1295 - return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra) 1296 - } 1297 - 1298 - n := extra 1299 - 1300 - nameBuf := make([]byte, 13) 1301 - for i := uint64(0); i < n; i++ { 1302 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1303 - if err != nil { 1304 - return err 1305 - } 1306 - 1307 - if !ok { 1308 - // Field doesn't exist on this type, so ignore it 1309 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1310 - return err 1311 - } 1312 - continue 1313 - } 1314 - 1315 - switch string(nameBuf[:nameLen]) { 1316 - // t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct) 1317 - case "commitCount": 1318 - 1319 - { 1320 - 1321 - b, err := cr.ReadByte() 1322 - if err != nil { 1323 - return err 1324 - } 1325 - if b != cbg.CborNull[0] { 1326 - if err := cr.UnreadByte(); err != nil { 1327 - return err 1328 - } 1329 - t.CommitCount = new(GitRefUpdate_Meta_CommitCount) 1330 - if err := t.CommitCount.UnmarshalCBOR(cr); err != nil { 1331 - return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err) 1332 - } 1333 - } 1334 - 1335 - } 1336 - // t.IsDefaultRef (bool) (bool) 1337 - case "isDefaultRef": 1338 - 1339 - maj, extra, err = cr.ReadHeader() 1340 - if err != nil { 1341 - return err 1342 - } 1343 - if maj != cbg.MajOther { 1344 - return fmt.Errorf("booleans must be major type 7") 1345 - } 1346 - switch extra { 1347 - case 20: 1348 - t.IsDefaultRef = false 1349 - case 21: 1350 - t.IsDefaultRef = true 1351 - default: 1352 - return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1353 - } 1354 - // t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct) 1355 - case "langBreakdown": 1356 - 1357 - { 1358 - 1359 - b, err := cr.ReadByte() 1360 - if err != nil { 1361 - return err 1362 - } 1363 - if b != cbg.CborNull[0] { 1364 - if err := cr.UnreadByte(); err != nil { 1365 - return err 1366 - } 1367 - t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown) 1368 - if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil { 1369 - return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err) 1370 - } 1371 - } 1372 - 1373 - } 1374 - 1375 - default: 1376 - // Field doesn't exist on this type, so ignore it 1377 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1378 - return err 1379 - } 1380 - } 1381 - } 1382 - 1383 - return nil 1384 - } 1385 - func (t *GitRefUpdate_Meta_CommitCount) MarshalCBOR(w io.Writer) error { 1205 + func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error { 1386 1206 if t == nil { 1387 1207 _, err := w.Write(cbg.CborNull) 1388 1208 return err ··· 1399 1219 return err 1400 1220 } 1401 1221 1402 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1222 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1403 1223 if t.ByEmail != nil { 1404 1224 1405 1225 if len("byEmail") > 1000000 { ··· 1430 1250 return nil 1431 1251 } 1432 1252 1433 - func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1434 - *t = GitRefUpdate_Meta_CommitCount{} 1253 + func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1254 + *t = GitRefUpdate_CommitCountBreakdown{} 1435 1255 1436 1256 cr := cbg.NewCborReader(r) 1437 1257 ··· 1450 1270 } 1451 1271 1452 1272 if extra > cbg.MaxLength { 1453 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra) 1273 + return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra) 1454 1274 } 1455 1275 1456 1276 n := extra ··· 1471 1291 } 1472 1292 1473 1293 switch string(nameBuf[:nameLen]) { 1474 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1294 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1475 1295 case "byEmail": 1476 1296 1477 1297 maj, extra, err = cr.ReadHeader() ··· 1488 1308 } 1489 1309 1490 1310 if extra > 0 { 1491 - t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra) 1311 + t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra) 1492 1312 } 1493 1313 1494 1314 for i := 0; i < int(extra); i++ { ··· 1510 1330 if err := cr.UnreadByte(); err != nil { 1511 1331 return err 1512 1332 } 1513 - t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem) 1333 + t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount) 1514 1334 if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1515 1335 return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1516 1336 } ··· 1531 1351 1532 1352 return nil 1533 1353 } 1534 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error { 1354 + func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error { 1535 1355 if t == nil { 1536 1356 _, err := w.Write(cbg.CborNull) 1537 1357 return err ··· 1590 1410 return nil 1591 1411 } 1592 1412 1593 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) { 1594 - *t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{} 1413 + func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1414 + *t = GitRefUpdate_IndividualEmailCommitCount{} 1595 1415 1596 1416 cr := cbg.NewCborReader(r) 1597 1417 ··· 1610 1430 } 1611 1431 1612 1432 if extra > cbg.MaxLength { 1613 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra) 1433 + return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra) 1614 1434 } 1615 1435 1616 1436 n := extra ··· 1679 1499 1680 1500 return nil 1681 1501 } 1682 - func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error { 1502 + func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1683 1503 if t == nil { 1684 1504 _, err := w.Write(cbg.CborNull) 1685 1505 return err ··· 1696 1516 return err 1697 1517 } 1698 1518 1699 - // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1519 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1700 1520 if t.Inputs != nil { 1701 1521 1702 1522 if len("inputs") > 1000000 { ··· 1727 1547 return nil 1728 1548 } 1729 1549 1730 - func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1731 - *t = GitRefUpdate_Meta_LangBreakdown{} 1550 + func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1551 + *t = GitRefUpdate_LangBreakdown{} 1732 1552 1733 1553 cr := cbg.NewCborReader(r) 1734 1554 ··· 1747 1567 } 1748 1568 1749 1569 if extra > cbg.MaxLength { 1750 - return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra) 1570 + return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1751 1571 } 1752 1572 1753 1573 n := extra ··· 1768 1588 } 1769 1589 1770 1590 switch string(nameBuf[:nameLen]) { 1771 - // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1591 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1772 1592 case "inputs": 1773 1593 1774 1594 maj, extra, err = cr.ReadHeader() ··· 1785 1605 } 1786 1606 1787 1607 if extra > 0 { 1788 - t.Inputs = make([]*GitRefUpdate_Pair, extra) 1608 + t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1789 1609 } 1790 1610 1791 1611 for i := 0; i < int(extra); i++ { ··· 1807 1627 if err := cr.UnreadByte(); err != nil { 1808 1628 return err 1809 1629 } 1810 - t.Inputs[i] = new(GitRefUpdate_Pair) 1630 + t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize) 1811 1631 if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1812 1632 return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1813 1633 } ··· 1828 1648 1829 1649 return nil 1830 1650 } 1831 - func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error { 1651 + func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1832 1652 if t == nil { 1833 1653 _, err := w.Write(cbg.CborNull) 1834 1654 return err ··· 1888 1708 return nil 1889 1709 } 1890 1710 1891 - func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) { 1892 - *t = GitRefUpdate_Pair{} 1711 + func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1712 + *t = GitRefUpdate_IndividualLanguageSize{} 1893 1713 1894 1714 cr := cbg.NewCborReader(r) 1895 1715 ··· 1908 1728 } 1909 1729 1910 1730 if extra > cbg.MaxLength { 1911 - return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra) 1731 + return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1912 1732 } 1913 1733 1914 1734 n := extra ··· 1977 1797 1978 1798 return nil 1979 1799 } 1800 + func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error { 1801 + if t == nil { 1802 + _, err := w.Write(cbg.CborNull) 1803 + return err 1804 + } 1805 + 1806 + cw := cbg.NewCborWriter(w) 1807 + fieldCount := 3 1808 + 1809 + if t.LangBreakdown == nil { 1810 + fieldCount-- 1811 + } 1812 + 1813 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1814 + return err 1815 + } 1816 + 1817 + // t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct) 1818 + if len("commitCount") > 1000000 { 1819 + return xerrors.Errorf("Value in field \"commitCount\" was too long") 1820 + } 1821 + 1822 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil { 1823 + return err 1824 + } 1825 + if _, err := cw.WriteString(string("commitCount")); err != nil { 1826 + return err 1827 + } 1828 + 1829 + if err := t.CommitCount.MarshalCBOR(cw); err != nil { 1830 + return err 1831 + } 1832 + 1833 + // t.IsDefaultRef (bool) (bool) 1834 + if len("isDefaultRef") > 1000000 { 1835 + return xerrors.Errorf("Value in field \"isDefaultRef\" was too long") 1836 + } 1837 + 1838 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil { 1839 + return err 1840 + } 1841 + if _, err := cw.WriteString(string("isDefaultRef")); err != nil { 1842 + return err 1843 + } 1844 + 1845 + if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1846 + return err 1847 + } 1848 + 1849 + // t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct) 1850 + if t.LangBreakdown != nil { 1851 + 1852 + if len("langBreakdown") > 1000000 { 1853 + return xerrors.Errorf("Value in field \"langBreakdown\" was too long") 1854 + } 1855 + 1856 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil { 1857 + return err 1858 + } 1859 + if _, err := cw.WriteString(string("langBreakdown")); err != nil { 1860 + return err 1861 + } 1862 + 1863 + if err := t.LangBreakdown.MarshalCBOR(cw); err != nil { 1864 + return err 1865 + } 1866 + } 1867 + return nil 1868 + } 1869 + 1870 + func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) { 1871 + *t = GitRefUpdate_Meta{} 1872 + 1873 + cr := cbg.NewCborReader(r) 1874 + 1875 + maj, extra, err := cr.ReadHeader() 1876 + if err != nil { 1877 + return err 1878 + } 1879 + defer func() { 1880 + if err == io.EOF { 1881 + err = io.ErrUnexpectedEOF 1882 + } 1883 + }() 1884 + 1885 + if maj != cbg.MajMap { 1886 + return fmt.Errorf("cbor input should be of type map") 1887 + } 1888 + 1889 + if extra > cbg.MaxLength { 1890 + return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra) 1891 + } 1892 + 1893 + n := extra 1894 + 1895 + nameBuf := make([]byte, 13) 1896 + for i := uint64(0); i < n; i++ { 1897 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1898 + if err != nil { 1899 + return err 1900 + } 1901 + 1902 + if !ok { 1903 + // Field doesn't exist on this type, so ignore it 1904 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1905 + return err 1906 + } 1907 + continue 1908 + } 1909 + 1910 + switch string(nameBuf[:nameLen]) { 1911 + // t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct) 1912 + case "commitCount": 1913 + 1914 + { 1915 + 1916 + b, err := cr.ReadByte() 1917 + if err != nil { 1918 + return err 1919 + } 1920 + if b != cbg.CborNull[0] { 1921 + if err := cr.UnreadByte(); err != nil { 1922 + return err 1923 + } 1924 + t.CommitCount = new(GitRefUpdate_CommitCountBreakdown) 1925 + if err := t.CommitCount.UnmarshalCBOR(cr); err != nil { 1926 + return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err) 1927 + } 1928 + } 1929 + 1930 + } 1931 + // t.IsDefaultRef (bool) (bool) 1932 + case "isDefaultRef": 1933 + 1934 + maj, extra, err = cr.ReadHeader() 1935 + if err != nil { 1936 + return err 1937 + } 1938 + if maj != cbg.MajOther { 1939 + return fmt.Errorf("booleans must be major type 7") 1940 + } 1941 + switch extra { 1942 + case 20: 1943 + t.IsDefaultRef = false 1944 + case 21: 1945 + t.IsDefaultRef = true 1946 + default: 1947 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1948 + } 1949 + // t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct) 1950 + case "langBreakdown": 1951 + 1952 + { 1953 + 1954 + b, err := cr.ReadByte() 1955 + if err != nil { 1956 + return err 1957 + } 1958 + if b != cbg.CborNull[0] { 1959 + if err := cr.UnreadByte(); err != nil { 1960 + return err 1961 + } 1962 + t.LangBreakdown = new(GitRefUpdate_LangBreakdown) 1963 + if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil { 1964 + return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err) 1965 + } 1966 + } 1967 + 1968 + } 1969 + 1970 + default: 1971 + // Field doesn't exist on this type, so ignore it 1972 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1973 + return err 1974 + } 1975 + } 1976 + } 1977 + 1978 + return nil 1979 + } 1980 1980 func (t *GraphFollow) MarshalCBOR(w io.Writer) error { 1981 1981 if t == nil { 1982 1982 _, err := w.Write(cbg.CborNull) ··· 2141 2141 2142 2142 return nil 2143 2143 } 2144 + func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 + if t == nil { 2146 + _, err := w.Write(cbg.CborNull) 2147 + return err 2148 + } 2149 + 2150 + cw := cbg.NewCborWriter(w) 2151 + 2152 + if _, err := cw.Write([]byte{162}); err != nil { 2153 + return err 2154 + } 2155 + 2156 + // t.LexiconTypeID (string) (string) 2157 + if len("$type") > 1000000 { 2158 + return xerrors.Errorf("Value in field \"$type\" was too long") 2159 + } 2160 + 2161 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 + return err 2163 + } 2164 + if _, err := cw.WriteString(string("$type")); err != nil { 2165 + return err 2166 + } 2167 + 2168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 + return err 2170 + } 2171 + if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 + return err 2173 + } 2174 + 2175 + // t.CreatedAt (string) (string) 2176 + if len("createdAt") > 1000000 { 2177 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 + } 2179 + 2180 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 + return err 2182 + } 2183 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 + return err 2185 + } 2186 + 2187 + if len(t.CreatedAt) > 1000000 { 2188 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 + } 2190 + 2191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 + return err 2193 + } 2194 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 + return err 2196 + } 2197 + return nil 2198 + } 2199 + 2200 + func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 + *t = Knot{} 2202 + 2203 + cr := cbg.NewCborReader(r) 2204 + 2205 + maj, extra, err := cr.ReadHeader() 2206 + if err != nil { 2207 + return err 2208 + } 2209 + defer func() { 2210 + if err == io.EOF { 2211 + err = io.ErrUnexpectedEOF 2212 + } 2213 + }() 2214 + 2215 + if maj != cbg.MajMap { 2216 + return fmt.Errorf("cbor input should be of type map") 2217 + } 2218 + 2219 + if extra > cbg.MaxLength { 2220 + return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 + } 2222 + 2223 + n := extra 2224 + 2225 + nameBuf := make([]byte, 9) 2226 + for i := uint64(0); i < n; i++ { 2227 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 + if err != nil { 2229 + return err 2230 + } 2231 + 2232 + if !ok { 2233 + // Field doesn't exist on this type, so ignore it 2234 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 + return err 2236 + } 2237 + continue 2238 + } 2239 + 2240 + switch string(nameBuf[:nameLen]) { 2241 + // t.LexiconTypeID (string) (string) 2242 + case "$type": 2243 + 2244 + { 2245 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 + if err != nil { 2247 + return err 2248 + } 2249 + 2250 + t.LexiconTypeID = string(sval) 2251 + } 2252 + // t.CreatedAt (string) (string) 2253 + case "createdAt": 2254 + 2255 + { 2256 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2257 + if err != nil { 2258 + return err 2259 + } 2260 + 2261 + t.CreatedAt = string(sval) 2262 + } 2263 + 2264 + default: 2265 + // Field doesn't exist on this type, so ignore it 2266 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2267 + return err 2268 + } 2269 + } 2270 + } 2271 + 2272 + return nil 2273 + } 2144 2274 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2145 2275 if t == nil { 2146 2276 _, err := w.Write(cbg.CborNull) ··· 2716 2846 t.Submodules = true 2717 2847 default: 2718 2848 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 2719 - } 2720 - 2721 - default: 2722 - // Field doesn't exist on this type, so ignore it 2723 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2724 - return err 2725 - } 2726 - } 2727 - } 2728 - 2729 - return nil 2730 - } 2731 - func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error { 2732 - if t == nil { 2733 - _, err := w.Write(cbg.CborNull) 2734 - return err 2735 - } 2736 - 2737 - cw := cbg.NewCborWriter(w) 2738 - 2739 - if _, err := cw.Write([]byte{162}); err != nil { 2740 - return err 2741 - } 2742 - 2743 - // t.Packages ([]string) (slice) 2744 - if len("packages") > 1000000 { 2745 - return xerrors.Errorf("Value in field \"packages\" was too long") 2746 - } 2747 - 2748 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil { 2749 - return err 2750 - } 2751 - if _, err := cw.WriteString(string("packages")); err != nil { 2752 - return err 2753 - } 2754 - 2755 - if len(t.Packages) > 8192 { 2756 - return xerrors.Errorf("Slice value in field t.Packages was too long") 2757 - } 2758 - 2759 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil { 2760 - return err 2761 - } 2762 - for _, v := range t.Packages { 2763 - if len(v) > 1000000 { 2764 - return xerrors.Errorf("Value in field v was too long") 2765 - } 2766 - 2767 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2768 - return err 2769 - } 2770 - if _, err := cw.WriteString(string(v)); err != nil { 2771 - return err 2772 - } 2773 - 2774 - } 2775 - 2776 - // t.Registry (string) (string) 2777 - if len("registry") > 1000000 { 2778 - return xerrors.Errorf("Value in field \"registry\" was too long") 2779 - } 2780 - 2781 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil { 2782 - return err 2783 - } 2784 - if _, err := cw.WriteString(string("registry")); err != nil { 2785 - return err 2786 - } 2787 - 2788 - if len(t.Registry) > 1000000 { 2789 - return xerrors.Errorf("Value in field t.Registry was too long") 2790 - } 2791 - 2792 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil { 2793 - return err 2794 - } 2795 - if _, err := cw.WriteString(string(t.Registry)); err != nil { 2796 - return err 2797 - } 2798 - return nil 2799 - } 2800 - 2801 - func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) { 2802 - *t = Pipeline_Dependency{} 2803 - 2804 - cr := cbg.NewCborReader(r) 2805 - 2806 - maj, extra, err := cr.ReadHeader() 2807 - if err != nil { 2808 - return err 2809 - } 2810 - defer func() { 2811 - if err == io.EOF { 2812 - err = io.ErrUnexpectedEOF 2813 - } 2814 - }() 2815 - 2816 - if maj != cbg.MajMap { 2817 - return fmt.Errorf("cbor input should be of type map") 2818 - } 2819 - 2820 - if extra > cbg.MaxLength { 2821 - return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra) 2822 - } 2823 - 2824 - n := extra 2825 - 2826 - nameBuf := make([]byte, 8) 2827 - for i := uint64(0); i < n; i++ { 2828 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2829 - if err != nil { 2830 - return err 2831 - } 2832 - 2833 - if !ok { 2834 - // Field doesn't exist on this type, so ignore it 2835 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2836 - return err 2837 - } 2838 - continue 2839 - } 2840 - 2841 - switch string(nameBuf[:nameLen]) { 2842 - // t.Packages ([]string) (slice) 2843 - case "packages": 2844 - 2845 - maj, extra, err = cr.ReadHeader() 2846 - if err != nil { 2847 - return err 2848 - } 2849 - 2850 - if extra > 8192 { 2851 - return fmt.Errorf("t.Packages: array too large (%d)", extra) 2852 - } 2853 - 2854 - if maj != cbg.MajArray { 2855 - return fmt.Errorf("expected cbor array") 2856 - } 2857 - 2858 - if extra > 0 { 2859 - t.Packages = make([]string, extra) 2860 - } 2861 - 2862 - for i := 0; i < int(extra); i++ { 2863 - { 2864 - var maj byte 2865 - var extra uint64 2866 - var err error 2867 - _ = maj 2868 - _ = extra 2869 - _ = err 2870 - 2871 - { 2872 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2873 - if err != nil { 2874 - return err 2875 - } 2876 - 2877 - t.Packages[i] = string(sval) 2878 - } 2879 - 2880 - } 2881 - } 2882 - // t.Registry (string) (string) 2883 - case "registry": 2884 - 2885 - { 2886 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2887 - if err != nil { 2888 - return err 2889 - } 2890 - 2891 - t.Registry = string(sval) 2892 2849 } 2893 2850 2894 2851 default: ··· 3916 3873 3917 3874 return nil 3918 3875 } 3919 - func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error { 3920 - if t == nil { 3921 - _, err := w.Write(cbg.CborNull) 3922 - return err 3923 - } 3924 - 3925 - cw := cbg.NewCborWriter(w) 3926 - fieldCount := 3 3927 - 3928 - if t.Environment == nil { 3929 - fieldCount-- 3930 - } 3931 - 3932 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3933 - return err 3934 - } 3935 - 3936 - // t.Name (string) (string) 3937 - if len("name") > 1000000 { 3938 - return xerrors.Errorf("Value in field \"name\" was too long") 3939 - } 3940 - 3941 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 3942 - return err 3943 - } 3944 - if _, err := cw.WriteString(string("name")); err != nil { 3945 - return err 3946 - } 3947 - 3948 - if len(t.Name) > 1000000 { 3949 - return xerrors.Errorf("Value in field t.Name was too long") 3950 - } 3951 - 3952 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 3953 - return err 3954 - } 3955 - if _, err := cw.WriteString(string(t.Name)); err != nil { 3956 - return err 3957 - } 3958 - 3959 - // t.Command (string) (string) 3960 - if len("command") > 1000000 { 3961 - return xerrors.Errorf("Value in field \"command\" was too long") 3962 - } 3963 - 3964 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil { 3965 - return err 3966 - } 3967 - if _, err := cw.WriteString(string("command")); err != nil { 3968 - return err 3969 - } 3970 - 3971 - if len(t.Command) > 1000000 { 3972 - return xerrors.Errorf("Value in field t.Command was too long") 3973 - } 3974 - 3975 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil { 3976 - return err 3977 - } 3978 - if _, err := cw.WriteString(string(t.Command)); err != nil { 3979 - return err 3980 - } 3981 - 3982 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 3983 - if t.Environment != nil { 3984 - 3985 - if len("environment") > 1000000 { 3986 - return xerrors.Errorf("Value in field \"environment\" was too long") 3987 - } 3988 - 3989 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 3990 - return err 3991 - } 3992 - if _, err := cw.WriteString(string("environment")); err != nil { 3993 - return err 3994 - } 3995 - 3996 - if len(t.Environment) > 8192 { 3997 - return xerrors.Errorf("Slice value in field t.Environment was too long") 3998 - } 3999 - 4000 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4001 - return err 4002 - } 4003 - for _, v := range t.Environment { 4004 - if err := v.MarshalCBOR(cw); err != nil { 4005 - return err 4006 - } 4007 - 4008 - } 4009 - } 4010 - return nil 4011 - } 4012 - 4013 - func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) { 4014 - *t = Pipeline_Step{} 4015 - 4016 - cr := cbg.NewCborReader(r) 4017 - 4018 - maj, extra, err := cr.ReadHeader() 4019 - if err != nil { 4020 - return err 4021 - } 4022 - defer func() { 4023 - if err == io.EOF { 4024 - err = io.ErrUnexpectedEOF 4025 - } 4026 - }() 4027 - 4028 - if maj != cbg.MajMap { 4029 - return fmt.Errorf("cbor input should be of type map") 4030 - } 4031 - 4032 - if extra > cbg.MaxLength { 4033 - return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra) 4034 - } 4035 - 4036 - n := extra 4037 - 4038 - nameBuf := make([]byte, 11) 4039 - for i := uint64(0); i < n; i++ { 4040 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4041 - if err != nil { 4042 - return err 4043 - } 4044 - 4045 - if !ok { 4046 - // Field doesn't exist on this type, so ignore it 4047 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4048 - return err 4049 - } 4050 - continue 4051 - } 4052 - 4053 - switch string(nameBuf[:nameLen]) { 4054 - // t.Name (string) (string) 4055 - case "name": 4056 - 4057 - { 4058 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 4059 - if err != nil { 4060 - return err 4061 - } 4062 - 4063 - t.Name = string(sval) 4064 - } 4065 - // t.Command (string) (string) 4066 - case "command": 4067 - 4068 - { 4069 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 4070 - if err != nil { 4071 - return err 4072 - } 4073 - 4074 - t.Command = string(sval) 4075 - } 4076 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4077 - case "environment": 4078 - 4079 - maj, extra, err = cr.ReadHeader() 4080 - if err != nil { 4081 - return err 4082 - } 4083 - 4084 - if extra > 8192 { 4085 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4086 - } 4087 - 4088 - if maj != cbg.MajArray { 4089 - return fmt.Errorf("expected cbor array") 4090 - } 4091 - 4092 - if extra > 0 { 4093 - t.Environment = make([]*Pipeline_Pair, extra) 4094 - } 4095 - 4096 - for i := 0; i < int(extra); i++ { 4097 - { 4098 - var maj byte 4099 - var extra uint64 4100 - var err error 4101 - _ = maj 4102 - _ = extra 4103 - _ = err 4104 - 4105 - { 4106 - 4107 - b, err := cr.ReadByte() 4108 - if err != nil { 4109 - return err 4110 - } 4111 - if b != cbg.CborNull[0] { 4112 - if err := cr.UnreadByte(); err != nil { 4113 - return err 4114 - } 4115 - t.Environment[i] = new(Pipeline_Pair) 4116 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4117 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4118 - } 4119 - } 4120 - 4121 - } 4122 - 4123 - } 4124 - } 4125 - 4126 - default: 4127 - // Field doesn't exist on this type, so ignore it 4128 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4129 - return err 4130 - } 4131 - } 4132 - } 4133 - 4134 - return nil 4135 - } 4136 3876 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 4137 3877 if t == nil { 4138 3878 _, err := w.Write(cbg.CborNull) ··· 4609 4349 4610 4350 cw := cbg.NewCborWriter(w) 4611 4351 4612 - if _, err := cw.Write([]byte{165}); err != nil { 4352 + if _, err := cw.Write([]byte{164}); err != nil { 4353 + return err 4354 + } 4355 + 4356 + // t.Raw (string) (string) 4357 + if len("raw") > 1000000 { 4358 + return xerrors.Errorf("Value in field \"raw\" was too long") 4359 + } 4360 + 4361 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4362 + return err 4363 + } 4364 + if _, err := cw.WriteString(string("raw")); err != nil { 4365 + return err 4366 + } 4367 + 4368 + if len(t.Raw) > 1000000 { 4369 + return xerrors.Errorf("Value in field t.Raw was too long") 4370 + } 4371 + 4372 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4373 + return err 4374 + } 4375 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4613 4376 return err 4614 4377 } 4615 4378 ··· 4652 4415 return err 4653 4416 } 4654 4417 4655 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4656 - if len("steps") > 1000000 { 4657 - return xerrors.Errorf("Value in field \"steps\" was too long") 4658 - } 4659 - 4660 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4661 - return err 4662 - } 4663 - if _, err := cw.WriteString(string("steps")); err != nil { 4664 - return err 4665 - } 4666 - 4667 - if len(t.Steps) > 8192 { 4668 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4669 - } 4670 - 4671 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4672 - return err 4673 - } 4674 - for _, v := range t.Steps { 4675 - if err := v.MarshalCBOR(cw); err != nil { 4676 - return err 4677 - } 4678 - 4679 - } 4680 - 4681 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4682 - if len("environment") > 1000000 { 4683 - return xerrors.Errorf("Value in field \"environment\" was too long") 4418 + // t.Engine (string) (string) 4419 + if len("engine") > 1000000 { 4420 + return xerrors.Errorf("Value in field \"engine\" was too long") 4684 4421 } 4685 4422 4686 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 4423 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4687 4424 return err 4688 4425 } 4689 - if _, err := cw.WriteString(string("environment")); err != nil { 4690 - return err 4691 - } 4692 - 4693 - if len(t.Environment) > 8192 { 4694 - return xerrors.Errorf("Slice value in field t.Environment was too long") 4695 - } 4696 - 4697 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4698 4427 return err 4699 4428 } 4700 - for _, v := range t.Environment { 4701 - if err := v.MarshalCBOR(cw); err != nil { 4702 - return err 4703 - } 4704 4429 4705 - } 4706 - 4707 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4708 - if len("dependencies") > 1000000 { 4709 - return xerrors.Errorf("Value in field \"dependencies\" was too long") 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4710 4432 } 4711 4433 4712 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil { 4713 - return err 4714 - } 4715 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4716 4435 return err 4717 4436 } 4718 - 4719 - if len(t.Dependencies) > 8192 { 4720 - return xerrors.Errorf("Slice value in field t.Dependencies was too long") 4721 - } 4722 - 4723 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil { 4437 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4724 4438 return err 4725 - } 4726 - for _, v := range t.Dependencies { 4727 - if err := v.MarshalCBOR(cw); err != nil { 4728 - return err 4729 - } 4730 - 4731 4439 } 4732 4440 return nil 4733 4441 } ··· 4757 4465 4758 4466 n := extra 4759 4467 4760 - nameBuf := make([]byte, 12) 4468 + nameBuf := make([]byte, 6) 4761 4469 for i := uint64(0); i < n; i++ { 4762 4470 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4763 4471 if err != nil { ··· 4773 4481 } 4774 4482 4775 4483 switch string(nameBuf[:nameLen]) { 4776 - // t.Name (string) (string) 4484 + // t.Raw (string) (string) 4485 + case "raw": 4486 + 4487 + { 4488 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4489 + if err != nil { 4490 + return err 4491 + } 4492 + 4493 + t.Raw = string(sval) 4494 + } 4495 + // t.Name (string) (string) 4777 4496 case "name": 4778 4497 4779 4498 { ··· 4804 4523 } 4805 4524 4806 4525 } 4807 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4808 - case "steps": 4809 - 4810 - maj, extra, err = cr.ReadHeader() 4811 - if err != nil { 4812 - return err 4813 - } 4814 - 4815 - if extra > 8192 { 4816 - return fmt.Errorf("t.Steps: array too large (%d)", extra) 4817 - } 4818 - 4819 - if maj != cbg.MajArray { 4820 - return fmt.Errorf("expected cbor array") 4821 - } 4822 - 4823 - if extra > 0 { 4824 - t.Steps = make([]*Pipeline_Step, extra) 4825 - } 4826 - 4827 - for i := 0; i < int(extra); i++ { 4828 - { 4829 - var maj byte 4830 - var extra uint64 4831 - var err error 4832 - _ = maj 4833 - _ = extra 4834 - _ = err 4835 - 4836 - { 4837 - 4838 - b, err := cr.ReadByte() 4839 - if err != nil { 4840 - return err 4841 - } 4842 - if b != cbg.CborNull[0] { 4843 - if err := cr.UnreadByte(); err != nil { 4844 - return err 4845 - } 4846 - t.Steps[i] = new(Pipeline_Step) 4847 - if err := t.Steps[i].UnmarshalCBOR(cr); err != nil { 4848 - return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err) 4849 - } 4850 - } 4851 - 4852 - } 4853 - 4854 - } 4855 - } 4856 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4857 - case "environment": 4858 - 4859 - maj, extra, err = cr.ReadHeader() 4860 - if err != nil { 4861 - return err 4862 - } 4863 - 4864 - if extra > 8192 { 4865 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4866 - } 4867 - 4868 - if maj != cbg.MajArray { 4869 - return fmt.Errorf("expected cbor array") 4870 - } 4871 - 4872 - if extra > 0 { 4873 - t.Environment = make([]*Pipeline_Pair, extra) 4874 - } 4875 - 4876 - for i := 0; i < int(extra); i++ { 4877 - { 4878 - var maj byte 4879 - var extra uint64 4880 - var err error 4881 - _ = maj 4882 - _ = extra 4883 - _ = err 4884 - 4885 - { 4886 - 4887 - b, err := cr.ReadByte() 4888 - if err != nil { 4889 - return err 4890 - } 4891 - if b != cbg.CborNull[0] { 4892 - if err := cr.UnreadByte(); err != nil { 4893 - return err 4894 - } 4895 - t.Environment[i] = new(Pipeline_Pair) 4896 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4897 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4898 - } 4899 - } 4900 - 4901 - } 4526 + // t.Engine (string) (string) 4527 + case "engine": 4902 4528 4529 + { 4530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4531 + if err != nil { 4532 + return err 4903 4533 } 4904 - } 4905 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4906 - case "dependencies": 4907 4534 4908 - maj, extra, err = cr.ReadHeader() 4909 - if err != nil { 4910 - return err 4911 - } 4912 - 4913 - if extra > 8192 { 4914 - return fmt.Errorf("t.Dependencies: array too large (%d)", extra) 4915 - } 4916 - 4917 - if maj != cbg.MajArray { 4918 - return fmt.Errorf("expected cbor array") 4919 - } 4920 - 4921 - if extra > 0 { 4922 - t.Dependencies = make([]*Pipeline_Dependency, extra) 4923 - } 4924 - 4925 - for i := 0; i < int(extra); i++ { 4926 - { 4927 - var maj byte 4928 - var extra uint64 4929 - var err error 4930 - _ = maj 4931 - _ = extra 4932 - _ = err 4933 - 4934 - { 4935 - 4936 - b, err := cr.ReadByte() 4937 - if err != nil { 4938 - return err 4939 - } 4940 - if b != cbg.CborNull[0] { 4941 - if err := cr.UnreadByte(); err != nil { 4942 - return err 4943 - } 4944 - t.Dependencies[i] = new(Pipeline_Dependency) 4945 - if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil { 4946 - return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err) 4947 - } 4948 - } 4949 - 4950 - } 4951 - 4952 - } 4535 + t.Engine = string(sval) 4953 4536 } 4954 4537 4955 4538 default: ··· 6059 5642 } 6060 5643 6061 5644 cw := cbg.NewCborWriter(w) 6062 - fieldCount := 7 5645 + fieldCount := 5 6063 5646 6064 5647 if t.Body == nil { 6065 5648 fieldCount-- ··· 6143 5726 return err 6144 5727 } 6145 5728 6146 - // t.Owner (string) (string) 6147 - if len("owner") > 1000000 { 6148 - return xerrors.Errorf("Value in field \"owner\" was too long") 6149 - } 6150 - 6151 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 6152 - return err 6153 - } 6154 - if _, err := cw.WriteString(string("owner")); err != nil { 6155 - return err 6156 - } 6157 - 6158 - if len(t.Owner) > 1000000 { 6159 - return xerrors.Errorf("Value in field t.Owner was too long") 6160 - } 6161 - 6162 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil { 6163 - return err 6164 - } 6165 - if _, err := cw.WriteString(string(t.Owner)); err != nil { 6166 - return err 6167 - } 6168 - 6169 5729 // t.Title (string) (string) 6170 5730 if len("title") > 1000000 { 6171 5731 return xerrors.Errorf("Value in field \"title\" was too long") ··· 6189 5749 return err 6190 5750 } 6191 5751 6192 - // t.IssueId (int64) (int64) 6193 - if len("issueId") > 1000000 { 6194 - return xerrors.Errorf("Value in field \"issueId\" was too long") 6195 - } 6196 - 6197 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 6198 - return err 6199 - } 6200 - if _, err := cw.WriteString(string("issueId")); err != nil { 6201 - return err 6202 - } 6203 - 6204 - if t.IssueId >= 0 { 6205 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 6206 - return err 6207 - } 6208 - } else { 6209 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 6210 - return err 6211 - } 6212 - } 6213 - 6214 5752 // t.CreatedAt (string) (string) 6215 5753 if len("createdAt") > 1000000 { 6216 5754 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6320 5858 6321 5859 t.LexiconTypeID = string(sval) 6322 5860 } 6323 - // t.Owner (string) (string) 6324 - case "owner": 6325 - 6326 - { 6327 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6328 - if err != nil { 6329 - return err 6330 - } 6331 - 6332 - t.Owner = string(sval) 6333 - } 6334 5861 // t.Title (string) (string) 6335 5862 case "title": 6336 5863 ··· 6342 5869 6343 5870 t.Title = string(sval) 6344 5871 } 6345 - // t.IssueId (int64) (int64) 6346 - case "issueId": 6347 - { 6348 - maj, extra, err := cr.ReadHeader() 6349 - if err != nil { 6350 - return err 6351 - } 6352 - var extraI int64 6353 - switch maj { 6354 - case cbg.MajUnsignedInt: 6355 - extraI = int64(extra) 6356 - if extraI < 0 { 6357 - return fmt.Errorf("int64 positive overflow") 6358 - } 6359 - case cbg.MajNegativeInt: 6360 - extraI = int64(extra) 6361 - if extraI < 0 { 6362 - return fmt.Errorf("int64 negative overflow") 6363 - } 6364 - extraI = -1 - extraI 6365 - default: 6366 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6367 - } 6368 - 6369 - t.IssueId = int64(extraI) 6370 - } 6371 5872 // t.CreatedAt (string) (string) 6372 5873 case "createdAt": 6373 5874 ··· 6397 5898 } 6398 5899 6399 5900 cw := cbg.NewCborWriter(w) 6400 - fieldCount := 7 6401 - 6402 - if t.CommentId == nil { 6403 - fieldCount-- 6404 - } 5901 + fieldCount := 6 6405 5902 6406 5903 if t.Owner == nil { 6407 5904 fieldCount-- ··· 6544 6041 } 6545 6042 } 6546 6043 6547 - // t.CommentId (int64) (int64) 6548 - if t.CommentId != nil { 6549 - 6550 - if len("commentId") > 1000000 { 6551 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6552 - } 6553 - 6554 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6555 - return err 6556 - } 6557 - if _, err := cw.WriteString(string("commentId")); err != nil { 6558 - return err 6559 - } 6560 - 6561 - if t.CommentId == nil { 6562 - if _, err := cw.Write(cbg.CborNull); err != nil { 6563 - return err 6564 - } 6565 - } else { 6566 - if *t.CommentId >= 0 { 6567 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6568 - return err 6569 - } 6570 - } else { 6571 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6572 - return err 6573 - } 6574 - } 6575 - } 6576 - 6577 - } 6578 - 6579 6044 // t.CreatedAt (string) (string) 6580 6045 if len("createdAt") > 1000000 { 6581 6046 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6717 6182 t.Owner = (*string)(&sval) 6718 6183 } 6719 6184 } 6720 - // t.CommentId (int64) (int64) 6721 - case "commentId": 6722 - { 6723 - 6724 - b, err := cr.ReadByte() 6725 - if err != nil { 6726 - return err 6727 - } 6728 - if b != cbg.CborNull[0] { 6729 - if err := cr.UnreadByte(); err != nil { 6730 - return err 6731 - } 6732 - maj, extra, err := cr.ReadHeader() 6733 - if err != nil { 6734 - return err 6735 - } 6736 - var extraI int64 6737 - switch maj { 6738 - case cbg.MajUnsignedInt: 6739 - extraI = int64(extra) 6740 - if extraI < 0 { 6741 - return fmt.Errorf("int64 positive overflow") 6742 - } 6743 - case cbg.MajNegativeInt: 6744 - extraI = int64(extra) 6745 - if extraI < 0 { 6746 - return fmt.Errorf("int64 negative overflow") 6747 - } 6748 - extraI = -1 - extraI 6749 - default: 6750 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6751 - } 6752 - 6753 - t.CommentId = (*int64)(&extraI) 6754 - } 6755 - } 6756 6185 // t.CreatedAt (string) (string) 6757 6186 case "createdAt": 6758 6187 ··· 6946 6375 } 6947 6376 6948 6377 cw := cbg.NewCborWriter(w) 6949 - fieldCount := 9 6378 + fieldCount := 7 6950 6379 6951 6380 if t.Body == nil { 6952 6381 fieldCount-- ··· 7057 6486 return err 7058 6487 } 7059 6488 7060 - // t.PullId (int64) (int64) 7061 - if len("pullId") > 1000000 { 7062 - return xerrors.Errorf("Value in field \"pullId\" was too long") 7063 - } 7064 - 7065 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil { 7066 - return err 7067 - } 7068 - if _, err := cw.WriteString(string("pullId")); err != nil { 7069 - return err 7070 - } 7071 - 7072 - if t.PullId >= 0 { 7073 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil { 7074 - return err 7075 - } 7076 - } else { 7077 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil { 7078 - return err 7079 - } 7080 - } 7081 - 7082 6489 // t.Source (tangled.RepoPull_Source) (struct) 7083 6490 if t.Source != nil { 7084 6491 ··· 7098 6505 } 7099 6506 } 7100 6507 7101 - // t.CreatedAt (string) (string) 7102 - if len("createdAt") > 1000000 { 7103 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 6508 + // t.Target (tangled.RepoPull_Target) (struct) 6509 + if len("target") > 1000000 { 6510 + return xerrors.Errorf("Value in field \"target\" was too long") 7104 6511 } 7105 6512 7106 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6513 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil { 7107 6514 return err 7108 6515 } 7109 - if _, err := cw.WriteString(string("createdAt")); err != nil { 6516 + if _, err := cw.WriteString(string("target")); err != nil { 7110 6517 return err 7111 6518 } 7112 6519 7113 - if len(t.CreatedAt) > 1000000 { 7114 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 7115 - } 7116 - 7117 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7118 - return err 7119 - } 7120 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6520 + if err := t.Target.MarshalCBOR(cw); err != nil { 7121 6521 return err 7122 6522 } 7123 6523 7124 - // t.TargetRepo (string) (string) 7125 - if len("targetRepo") > 1000000 { 7126 - return xerrors.Errorf("Value in field \"targetRepo\" was too long") 7127 - } 7128 - 7129 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil { 7130 - return err 7131 - } 7132 - if _, err := cw.WriteString(string("targetRepo")); err != nil { 7133 - return err 7134 - } 7135 - 7136 - if len(t.TargetRepo) > 1000000 { 7137 - return xerrors.Errorf("Value in field t.TargetRepo was too long") 7138 - } 7139 - 7140 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil { 7141 - return err 7142 - } 7143 - if _, err := cw.WriteString(string(t.TargetRepo)); err != nil { 7144 - return err 7145 - } 7146 - 7147 - // t.TargetBranch (string) (string) 7148 - if len("targetBranch") > 1000000 { 7149 - return xerrors.Errorf("Value in field \"targetBranch\" was too long") 6524 + // t.CreatedAt (string) (string) 6525 + if len("createdAt") > 1000000 { 6526 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7150 6527 } 7151 6528 7152 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil { 6529 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7153 6530 return err 7154 6531 } 7155 - if _, err := cw.WriteString(string("targetBranch")); err != nil { 6532 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7156 6533 return err 7157 6534 } 7158 6535 7159 - if len(t.TargetBranch) > 1000000 { 7160 - return xerrors.Errorf("Value in field t.TargetBranch was too long") 6536 + if len(t.CreatedAt) > 1000000 { 6537 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7161 6538 } 7162 6539 7163 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil { 6540 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7164 6541 return err 7165 6542 } 7166 - if _, err := cw.WriteString(string(t.TargetBranch)); err != nil { 6543 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7167 6544 return err 7168 6545 } 7169 6546 return nil ··· 7194 6571 7195 6572 n := extra 7196 6573 7197 - nameBuf := make([]byte, 12) 6574 + nameBuf := make([]byte, 9) 7198 6575 for i := uint64(0); i < n; i++ { 7199 6576 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7200 6577 if err != nil { ··· 7264 6641 7265 6642 t.Title = string(sval) 7266 6643 } 7267 - // t.PullId (int64) (int64) 7268 - case "pullId": 7269 - { 7270 - maj, extra, err := cr.ReadHeader() 7271 - if err != nil { 7272 - return err 7273 - } 7274 - var extraI int64 7275 - switch maj { 7276 - case cbg.MajUnsignedInt: 7277 - extraI = int64(extra) 7278 - if extraI < 0 { 7279 - return fmt.Errorf("int64 positive overflow") 7280 - } 7281 - case cbg.MajNegativeInt: 7282 - extraI = int64(extra) 7283 - if extraI < 0 { 7284 - return fmt.Errorf("int64 negative overflow") 7285 - } 7286 - extraI = -1 - extraI 7287 - default: 7288 - return fmt.Errorf("wrong type for int64 field: %d", maj) 7289 - } 7290 - 7291 - t.PullId = int64(extraI) 7292 - } 7293 6644 // t.Source (tangled.RepoPull_Source) (struct) 7294 6645 case "source": 7295 6646 ··· 7310 6661 } 7311 6662 7312 6663 } 7313 - // t.CreatedAt (string) (string) 7314 - case "createdAt": 6664 + // t.Target (tangled.RepoPull_Target) (struct) 6665 + case "target": 7315 6666 7316 6667 { 7317 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6668 + 6669 + b, err := cr.ReadByte() 7318 6670 if err != nil { 7319 6671 return err 7320 6672 } 7321 - 7322 - t.CreatedAt = string(sval) 7323 - } 7324 - // t.TargetRepo (string) (string) 7325 - case "targetRepo": 7326 - 7327 - { 7328 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7329 - if err != nil { 7330 - return err 6673 + if b != cbg.CborNull[0] { 6674 + if err := cr.UnreadByte(); err != nil { 6675 + return err 6676 + } 6677 + t.Target = new(RepoPull_Target) 6678 + if err := t.Target.UnmarshalCBOR(cr); err != nil { 6679 + return xerrors.Errorf("unmarshaling t.Target pointer: %w", err) 6680 + } 7331 6681 } 7332 6682 7333 - t.TargetRepo = string(sval) 7334 6683 } 7335 - // t.TargetBranch (string) (string) 7336 - case "targetBranch": 6684 + // t.CreatedAt (string) (string) 6685 + case "createdAt": 7337 6686 7338 6687 { 7339 6688 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 7341 6690 return err 7342 6691 } 7343 6692 7344 - t.TargetBranch = string(sval) 6693 + t.CreatedAt = string(sval) 7345 6694 } 7346 6695 7347 6696 default: ··· 7361 6710 } 7362 6711 7363 6712 cw := cbg.NewCborWriter(w) 7364 - fieldCount := 7 7365 6713 7366 - if t.CommentId == nil { 7367 - fieldCount-- 7368 - } 7369 - 7370 - if t.Owner == nil { 7371 - fieldCount-- 7372 - } 7373 - 7374 - if t.Repo == nil { 7375 - fieldCount-- 7376 - } 7377 - 7378 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6714 + if _, err := cw.Write([]byte{164}); err != nil { 7379 6715 return err 7380 6716 } 7381 6717 ··· 7425 6761 return err 7426 6762 } 7427 6763 7428 - // t.Repo (string) (string) 7429 - if t.Repo != nil { 7430 - 7431 - if len("repo") > 1000000 { 7432 - return xerrors.Errorf("Value in field \"repo\" was too long") 7433 - } 7434 - 7435 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7436 - return err 7437 - } 7438 - if _, err := cw.WriteString(string("repo")); err != nil { 7439 - return err 7440 - } 7441 - 7442 - if t.Repo == nil { 7443 - if _, err := cw.Write(cbg.CborNull); err != nil { 7444 - return err 7445 - } 7446 - } else { 7447 - if len(*t.Repo) > 1000000 { 7448 - return xerrors.Errorf("Value in field t.Repo was too long") 7449 - } 7450 - 7451 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 7452 - return err 7453 - } 7454 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 7455 - return err 7456 - } 7457 - } 7458 - } 7459 - 7460 6764 // t.LexiconTypeID (string) (string) 7461 6765 if len("$type") > 1000000 { 7462 6766 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 7476 6780 return err 7477 6781 } 7478 6782 7479 - // t.Owner (string) (string) 7480 - if t.Owner != nil { 7481 - 7482 - if len("owner") > 1000000 { 7483 - return xerrors.Errorf("Value in field \"owner\" was too long") 7484 - } 7485 - 7486 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 7487 - return err 7488 - } 7489 - if _, err := cw.WriteString(string("owner")); err != nil { 7490 - return err 7491 - } 7492 - 7493 - if t.Owner == nil { 7494 - if _, err := cw.Write(cbg.CborNull); err != nil { 7495 - return err 7496 - } 7497 - } else { 7498 - if len(*t.Owner) > 1000000 { 7499 - return xerrors.Errorf("Value in field t.Owner was too long") 7500 - } 7501 - 7502 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 7503 - return err 7504 - } 7505 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 7506 - return err 7507 - } 7508 - } 7509 - } 7510 - 7511 - // t.CommentId (int64) (int64) 7512 - if t.CommentId != nil { 7513 - 7514 - if len("commentId") > 1000000 { 7515 - return xerrors.Errorf("Value in field \"commentId\" was too long") 7516 - } 7517 - 7518 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 7519 - return err 7520 - } 7521 - if _, err := cw.WriteString(string("commentId")); err != nil { 7522 - return err 7523 - } 7524 - 7525 - if t.CommentId == nil { 7526 - if _, err := cw.Write(cbg.CborNull); err != nil { 7527 - return err 7528 - } 7529 - } else { 7530 - if *t.CommentId >= 0 { 7531 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 7532 - return err 7533 - } 7534 - } else { 7535 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 7536 - return err 7537 - } 7538 - } 7539 - } 7540 - 7541 - } 7542 - 7543 6783 // t.CreatedAt (string) (string) 7544 6784 if len("createdAt") > 1000000 { 7545 6785 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7628 6868 7629 6869 t.Pull = string(sval) 7630 6870 } 7631 - // t.Repo (string) (string) 7632 - case "repo": 7633 - 7634 - { 7635 - b, err := cr.ReadByte() 7636 - if err != nil { 7637 - return err 7638 - } 7639 - if b != cbg.CborNull[0] { 7640 - if err := cr.UnreadByte(); err != nil { 7641 - return err 7642 - } 7643 - 7644 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7645 - if err != nil { 7646 - return err 7647 - } 7648 - 7649 - t.Repo = (*string)(&sval) 7650 - } 7651 - } 7652 6871 // t.LexiconTypeID (string) (string) 7653 6872 case "$type": 7654 6873 ··· 7659 6878 } 7660 6879 7661 6880 t.LexiconTypeID = string(sval) 7662 - } 7663 - // t.Owner (string) (string) 7664 - case "owner": 7665 - 7666 - { 7667 - b, err := cr.ReadByte() 7668 - if err != nil { 7669 - return err 7670 - } 7671 - if b != cbg.CborNull[0] { 7672 - if err := cr.UnreadByte(); err != nil { 7673 - return err 7674 - } 7675 - 7676 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7677 - if err != nil { 7678 - return err 7679 - } 7680 - 7681 - t.Owner = (*string)(&sval) 7682 - } 7683 - } 7684 - // t.CommentId (int64) (int64) 7685 - case "commentId": 7686 - { 7687 - 7688 - b, err := cr.ReadByte() 7689 - if err != nil { 7690 - return err 7691 - } 7692 - if b != cbg.CborNull[0] { 7693 - if err := cr.UnreadByte(); err != nil { 7694 - return err 7695 - } 7696 - maj, extra, err := cr.ReadHeader() 7697 - if err != nil { 7698 - return err 7699 - } 7700 - var extraI int64 7701 - switch maj { 7702 - case cbg.MajUnsignedInt: 7703 - extraI = int64(extra) 7704 - if extraI < 0 { 7705 - return fmt.Errorf("int64 positive overflow") 7706 - } 7707 - case cbg.MajNegativeInt: 7708 - extraI = int64(extra) 7709 - if extraI < 0 { 7710 - return fmt.Errorf("int64 negative overflow") 7711 - } 7712 - extraI = -1 - extraI 7713 - default: 7714 - return fmt.Errorf("wrong type for int64 field: %d", maj) 7715 - } 7716 - 7717 - t.CommentId = (*int64)(&extraI) 7718 - } 7719 6881 } 7720 6882 // t.CreatedAt (string) (string) 7721 6883 case "createdAt": ··· 8083 7245 } 8084 7246 8085 7247 t.Status = string(sval) 7248 + } 7249 + 7250 + default: 7251 + // Field doesn't exist on this type, so ignore it 7252 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7253 + return err 7254 + } 7255 + } 7256 + } 7257 + 7258 + return nil 7259 + } 7260 + func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error { 7261 + if t == nil { 7262 + _, err := w.Write(cbg.CborNull) 7263 + return err 7264 + } 7265 + 7266 + cw := cbg.NewCborWriter(w) 7267 + 7268 + if _, err := cw.Write([]byte{162}); err != nil { 7269 + return err 7270 + } 7271 + 7272 + // t.Repo (string) (string) 7273 + if len("repo") > 1000000 { 7274 + return xerrors.Errorf("Value in field \"repo\" was too long") 7275 + } 7276 + 7277 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7278 + return err 7279 + } 7280 + if _, err := cw.WriteString(string("repo")); err != nil { 7281 + return err 7282 + } 7283 + 7284 + if len(t.Repo) > 1000000 { 7285 + return xerrors.Errorf("Value in field t.Repo was too long") 7286 + } 7287 + 7288 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 7289 + return err 7290 + } 7291 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 7292 + return err 7293 + } 7294 + 7295 + // t.Branch (string) (string) 7296 + if len("branch") > 1000000 { 7297 + return xerrors.Errorf("Value in field \"branch\" was too long") 7298 + } 7299 + 7300 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil { 7301 + return err 7302 + } 7303 + if _, err := cw.WriteString(string("branch")); err != nil { 7304 + return err 7305 + } 7306 + 7307 + if len(t.Branch) > 1000000 { 7308 + return xerrors.Errorf("Value in field t.Branch was too long") 7309 + } 7310 + 7311 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil { 7312 + return err 7313 + } 7314 + if _, err := cw.WriteString(string(t.Branch)); err != nil { 7315 + return err 7316 + } 7317 + return nil 7318 + } 7319 + 7320 + func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) { 7321 + *t = RepoPull_Target{} 7322 + 7323 + cr := cbg.NewCborReader(r) 7324 + 7325 + maj, extra, err := cr.ReadHeader() 7326 + if err != nil { 7327 + return err 7328 + } 7329 + defer func() { 7330 + if err == io.EOF { 7331 + err = io.ErrUnexpectedEOF 7332 + } 7333 + }() 7334 + 7335 + if maj != cbg.MajMap { 7336 + return fmt.Errorf("cbor input should be of type map") 7337 + } 7338 + 7339 + if extra > cbg.MaxLength { 7340 + return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra) 7341 + } 7342 + 7343 + n := extra 7344 + 7345 + nameBuf := make([]byte, 6) 7346 + for i := uint64(0); i < n; i++ { 7347 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7348 + if err != nil { 7349 + return err 7350 + } 7351 + 7352 + if !ok { 7353 + // Field doesn't exist on this type, so ignore it 7354 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7355 + return err 7356 + } 7357 + continue 7358 + } 7359 + 7360 + switch string(nameBuf[:nameLen]) { 7361 + // t.Repo (string) (string) 7362 + case "repo": 7363 + 7364 + { 7365 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7366 + if err != nil { 7367 + return err 7368 + } 7369 + 7370 + t.Repo = string(sval) 7371 + } 7372 + // t.Branch (string) (string) 7373 + case "branch": 7374 + 7375 + { 7376 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7377 + if err != nil { 7378 + return err 7379 + } 7380 + 7381 + t.Branch = string(sval) 8086 7382 } 8087 7383 8088 7384 default:
+19 -15
api/tangled/gitrefUpdate.go
··· 33 33 RepoName string `json:"repoName" cborgen:"repoName"` 34 34 } 35 35 36 - type GitRefUpdate_Meta struct { 37 - CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 - IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 39 - LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"` 36 + // GitRefUpdate_CommitCountBreakdown is a "commitCountBreakdown" in the sh.tangled.git.refUpdate schema. 37 + type GitRefUpdate_CommitCountBreakdown struct { 38 + ByEmail []*GitRefUpdate_IndividualEmailCommitCount `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"` 40 39 } 41 40 42 - type GitRefUpdate_Meta_CommitCount struct { 43 - ByEmail []*GitRefUpdate_Meta_CommitCount_ByEmail_Elem `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"` 44 - } 45 - 46 - type GitRefUpdate_Meta_CommitCount_ByEmail_Elem struct { 41 + // GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema. 42 + type GitRefUpdate_IndividualEmailCommitCount struct { 47 43 Count int64 `json:"count" cborgen:"count"` 48 44 Email string `json:"email" cborgen:"email"` 49 45 } 50 46 51 - type GitRefUpdate_Meta_LangBreakdown struct { 52 - Inputs []*GitRefUpdate_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 53 - } 54 - 55 - // GitRefUpdate_Pair is a "pair" in the sh.tangled.git.refUpdate schema. 56 - type GitRefUpdate_Pair struct { 47 + // GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema. 48 + type GitRefUpdate_IndividualLanguageSize struct { 57 49 Lang string `json:"lang" cborgen:"lang"` 58 50 Size int64 `json:"size" cborgen:"size"` 59 51 } 52 + 53 + // GitRefUpdate_LangBreakdown is a "langBreakdown" in the sh.tangled.git.refUpdate schema. 54 + type GitRefUpdate_LangBreakdown struct { 55 + Inputs []*GitRefUpdate_IndividualLanguageSize `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 56 + } 57 + 58 + // GitRefUpdate_Meta is a "meta" in the sh.tangled.git.refUpdate schema. 59 + type GitRefUpdate_Meta struct { 60 + CommitCount *GitRefUpdate_CommitCountBreakdown `json:"commitCount" cborgen:"commitCount"` 61 + IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 62 + LangBreakdown *GitRefUpdate_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"` 63 + }
-1
api/tangled/issuecomment.go
··· 19 19 type RepoIssueComment struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 23 Issue string `json:"issue" cborgen:"issue"` 25 24 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
+4 -7
api/tangled/pullcomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPullComment 19 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Pull string `json:"pull" cborgen:"pull"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Pull string `json:"pull" cborgen:"pull"` 27 24 }
+34
api/tangled/repocreate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.create 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoCreateNSID = "sh.tangled.repo.create" 15 + ) 16 + 17 + // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 + type RepoCreate_Input struct { 19 + // defaultBranch: Default branch to push to 20 + DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 + // rkey: Rkey of the repository record 22 + Rkey string `json:"rkey" cborgen:"rkey"` 23 + // source: A source URL to clone from, populate this when forking or importing a repository. 24 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + } 26 + 27 + // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 + func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+34
api/tangled/repodelete.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.delete 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteNSID = "sh.tangled.repo.delete" 15 + ) 16 + 17 + // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. 18 + type RepoDelete_Input struct { 19 + // did: DID of the repository owner 20 + Did string `json:"did" cborgen:"did"` 21 + // name: Name of the repository to delete 22 + Name string `json:"name" cborgen:"name"` 23 + // rkey: Rkey of the repository record 24 + Rkey string `json:"rkey" cborgen:"rkey"` 25 + } 26 + 27 + // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 28 + func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+45
api/tangled/repoforkStatus.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkStatus 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 + ) 16 + 17 + // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 + type RepoForkStatus_Input struct { 19 + // branch: Branch to check status for 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // hiddenRef: Hidden ref to use for comparison 24 + HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 + // name: Name of the forked repository 26 + Name string `json:"name" cborgen:"name"` 27 + // source: Source repository URL 28 + Source string `json:"source" cborgen:"source"` 29 + } 30 + 31 + // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 + type RepoForkStatus_Output struct { 33 + // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 + Status int64 `json:"status" cborgen:"status"` 35 + } 36 + 37 + // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 + func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 + var out RepoForkStatus_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+36
api/tangled/repoforkSync.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkSync 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 + ) 16 + 17 + // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 + type RepoForkSync_Input struct { 19 + // branch: Branch to sync 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // name: Name of the forked repository 24 + Name string `json:"name" cborgen:"name"` 25 + // source: AT-URI of the source repository 26 + Source string `json:"source" cborgen:"source"` 27 + } 28 + 29 + // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 + func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 + return err 33 + } 34 + 35 + return nil 36 + }
+45
api/tangled/repohiddenRef.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
-2
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 - Owner string `json:"owner" cborgen:"owner"` 25 23 Repo string `json:"repo" cborgen:"repo"` 26 24 Title string `json:"title" cborgen:"title"` 27 25 }
+44
api/tangled/repomerge.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.merge 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeNSID = "sh.tangled.repo.merge" 15 + ) 16 + 17 + // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 + type RepoMerge_Input struct { 19 + // authorEmail: Author email for the merge commit 20 + AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 + // authorName: Author name for the merge commit 22 + AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 + // branch: Target branch to merge into 24 + Branch string `json:"branch" cborgen:"branch"` 25 + // commitBody: Additional commit message body 26 + CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 + // commitMessage: Merge commit message 28 + CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch content to merge 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 + func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 + return err 41 + } 42 + 43 + return nil 44 + }
+57
api/tangled/repomergeCheck.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 + ) 16 + 17 + // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 + type RepoMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 + type RepoMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch or pull request to check for merge conflicts 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 + type RepoMergeCheck_Output struct { 39 + // conflicts: List of files with merge conflicts 40 + Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 + // error: Error message if check failed 42 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 + // is_conflicted: Whether the merge has conflicts 44 + Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 + // message: Additional message about the merge check 46 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 + } 48 + 49 + // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 + func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 + var out RepoMergeCheck_Output 52 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + }
+7 -3
api/tangled/repopull.go
··· 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 23 Patch string `json:"patch" cborgen:"patch"` 24 - PullId int64 `json:"pullId" cborgen:"pullId"` 25 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 26 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 27 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 25 + Target *RepoPull_Target `json:"target" cborgen:"target"` 28 26 Title string `json:"title" cborgen:"title"` 29 27 } 30 28 ··· 34 32 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 35 33 Sha string `json:"sha" cborgen:"sha"` 36 34 } 35 + 36 + // RepoPull_Target is a "target" in the sh.tangled.repo.pull schema. 37 + type RepoPull_Target struct { 38 + Branch string `json:"branch" cborgen:"branch"` 39 + Repo string `json:"repo" cborgen:"repo"` 40 + }
+22
api/tangled/tangledknot.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + KnotNSID = "sh.tangled.knot" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.knot", &Knot{}) 17 + } // 18 + // RECORDTYPE: Knot 19 + type Knot struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+4 -18
api/tangled/tangledpipeline.go
··· 29 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 30 } 31 31 32 - // Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema. 33 - type Pipeline_Dependency struct { 34 - Packages []string `json:"packages" cborgen:"packages"` 35 - Registry string `json:"registry" cborgen:"registry"` 36 - } 37 - 38 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 39 33 type Pipeline_ManualTriggerData struct { 40 34 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 61 55 Ref string `json:"ref" cborgen:"ref"` 62 56 } 63 57 64 - // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 65 - type Pipeline_Step struct { 66 - Command string `json:"command" cborgen:"command"` 67 - Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 - Name string `json:"name" cborgen:"name"` 69 - } 70 - 71 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 72 59 type Pipeline_TriggerMetadata struct { 73 60 Kind string `json:"kind" cborgen:"kind"` ··· 87 74 88 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 89 76 type Pipeline_Workflow struct { 90 - Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 91 - Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"` 92 - Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"` 93 - Name string `json:"name" cborgen:"name"` 94 - Steps []*Pipeline_Step `json:"steps" cborgen:"steps"` 77 + Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 78 + Engine string `json:"engine" cborgen:"engine"` 79 + Name string `json:"name" cborgen:"name"` 80 + Raw string `json:"raw" cborgen:"raw"` 95 81 }
+1
appview/cache/session/store.go
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+4 -1
appview/config/config.go
··· 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 19 20 - // temporarily, to add users to default spindle 20 + // temporarily, to add users to default knot and spindle 21 21 AppPassword string `env:"APP_PASSWORD"` 22 + 23 + // uhhhh this is because knot1 is under icy's did 24 + TmpAltAppPassword string `env:"ALT_APP_PASSWORD"` 22 25 } 23 26 24 27 type OAuthConfig struct {
+71 -23
appview/db/db.go
··· 27 27 } 28 28 29 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 30 + // https://github.com/mattn/go-sqlite3#connection-string 31 + opts := []string{ 32 + "_foreign_keys=1", 33 + "_journal_mode=WAL", 34 + "_synchronous=NORMAL", 35 + "_auto_vacuum=incremental", 36 + } 37 + 38 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 39 if err != nil { 32 40 return nil, err 33 41 } 34 - _, err = db.Exec(` 35 - pragma journal_mode = WAL; 36 - pragma synchronous = normal; 37 - pragma foreign_keys = on; 38 - pragma temp_store = memory; 39 - pragma mmap_size = 30000000000; 40 - pragma page_size = 32768; 41 - pragma auto_vacuum = incremental; 42 - pragma busy_timeout = 5000; 42 + 43 + ctx := context.Background() 43 44 45 + conn, err := db.Conn(ctx) 46 + if err != nil { 47 + return nil, err 48 + } 49 + defer conn.Close() 50 + 51 + _, err = conn.ExecContext(ctx, ` 44 52 create table if not exists registrations ( 45 53 id integer primary key autoincrement, 46 54 domain text not null unique, ··· 462 470 id integer primary key autoincrement, 463 471 name text unique 464 472 ); 473 + 474 + -- indexes for better star query performance 475 + create index if not exists idx_stars_created on stars(created); 476 + create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 465 477 `) 466 478 if err != nil { 467 479 return nil, err 468 480 } 469 481 470 482 // run migrations 471 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 472 484 tx.Exec(` 473 485 alter table repos add column description text check (length(description) <= 200); 474 486 `) 475 487 return nil 476 488 }) 477 489 478 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 479 491 // add unconstrained column 480 492 _, err := tx.Exec(` 481 493 alter table public_keys ··· 498 510 return nil 499 511 }) 500 512 501 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 502 514 _, err := tx.Exec(` 503 515 alter table comments drop column comment_at; 504 516 alter table comments add column rkey text; ··· 506 518 return err 507 519 }) 508 520 509 - runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 521 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 510 522 _, err := tx.Exec(` 511 523 alter table comments add column deleted text; -- timestamp 512 524 alter table comments add column edited text; -- timestamp ··· 514 526 return err 515 527 }) 516 528 517 - runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 529 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 518 530 _, err := tx.Exec(` 519 531 alter table pulls add column source_branch text; 520 532 alter table pulls add column source_repo_at text; ··· 523 535 return err 524 536 }) 525 537 526 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 527 539 _, err := tx.Exec(` 528 540 alter table repos add column source text; 529 541 `) ··· 534 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 535 547 // 536 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 537 - db.Exec("pragma foreign_keys = off;") 538 - runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 549 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 550 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 539 551 _, err := tx.Exec(` 540 552 create table pulls_new ( 541 553 -- identifiers ··· 590 602 `) 591 603 return err 592 604 }) 593 - db.Exec("pragma foreign_keys = on;") 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 594 606 595 607 // run migrations 596 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 597 609 tx.Exec(` 598 610 alter table repos add column spindle text; 599 611 `) 600 612 return nil 601 613 }) 602 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 603 640 // recreate and add rkey + created columns with default constraint 604 - runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 641 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 605 642 // create new table 606 643 // - repo_at instead of repo integer 607 644 // - rkey field ··· 655 692 return err 656 693 }) 657 694 695 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 696 + _, err := tx.Exec(` 697 + alter table issues add column rkey text not null default ''; 698 + 699 + -- get last url section from issue_at and save to rkey column 700 + update issues 701 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 702 + `) 703 + return err 704 + }) 705 + 658 706 return &DB{db}, nil 659 707 } 660 708 661 709 type migrationFn = func(*sql.Tx) error 662 710 663 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 664 - tx, err := d.Begin() 711 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 712 + tx, err := c.BeginTx(context.Background(), nil) 665 713 if err != nil { 666 714 return err 667 715 }
+145 -42
appview/db/follow.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 6 + "strings" 5 7 "time" 6 8 ) 7 9 ··· 53 55 return err 54 56 } 55 57 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 57 - followers, following := 0, 0 58 + type FollowStats struct { 59 + Followers int64 60 + Following int64 61 + } 62 + 63 + func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 + var followers, following int64 58 65 err := e.QueryRow( 59 - `SELECT 66 + `SELECT 60 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 61 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 62 69 FROM follows;`, did, did).Scan(&followers, &following) 63 70 if err != nil { 64 - return 0, 0, err 71 + return FollowStats{}, err 65 72 } 66 - return followers, following, nil 73 + return FollowStats{ 74 + Followers: followers, 75 + Following: following, 76 + }, nil 67 77 } 68 78 69 - type FollowStatus int 79 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 + if len(dids) == 0 { 81 + return nil, nil 82 + } 70 83 71 - const ( 72 - IsNotFollowing FollowStatus = iota 73 - IsFollowing 74 - IsSelf 75 - ) 84 + placeholders := make([]string, len(dids)) 85 + for i := range placeholders { 86 + placeholders[i] = "?" 87 + } 88 + placeholderStr := strings.Join(placeholders, ",") 76 89 77 - func (s FollowStatus) String() string { 78 - switch s { 79 - case IsNotFollowing: 80 - return "IsNotFollowing" 81 - case IsFollowing: 82 - return "IsFollowing" 83 - case IsSelf: 84 - return "IsSelf" 85 - default: 86 - return "IsNotFollowing" 90 + args := make([]any, len(dids)*2) 91 + for i, did := range dids { 92 + args[i] = did 93 + args[i+len(dids)] = did 87 94 } 88 - } 95 + 96 + query := fmt.Sprintf(` 97 + select 98 + coalesce(f.did, g.did) as did, 99 + coalesce(f.followers, 0) as followers, 100 + coalesce(g.following, 0) as following 101 + from ( 102 + select subject_did as did, count(*) as followers 103 + from follows 104 + where subject_did in (%s) 105 + group by subject_did 106 + ) f 107 + full outer join ( 108 + select user_did as did, count(*) as following 109 + from follows 110 + where user_did in (%s) 111 + group by user_did 112 + ) g on f.did = g.did`, 113 + placeholderStr, placeholderStr) 114 + 115 + result := make(map[string]FollowStats) 116 + 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 120 + } 121 + defer rows.Close() 122 + 123 + for rows.Next() { 124 + var did string 125 + var followers, following int64 126 + if err := rows.Scan(&did, &followers, &following); err != nil { 127 + return nil, err 128 + } 129 + result[did] = FollowStats{ 130 + Followers: followers, 131 + Following: following, 132 + } 133 + } 89 134 90 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 - if userDid == subjectDid { 92 - return IsSelf 93 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 - return IsNotFollowing 95 - } else { 96 - return IsFollowing 135 + for _, did := range dids { 136 + if _, exists := result[did]; !exists { 137 + result[did] = FollowStats{ 138 + Followers: 0, 139 + Following: 0, 140 + } 141 + } 97 142 } 143 + 144 + return result, nil 98 145 } 99 146 100 - func GetAllFollows(e Execer, limit int) ([]Follow, error) { 147 + func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 101 148 var follows []Follow 102 149 103 - rows, err := e.Query(` 104 - select user_did, subject_did, followed_at, rkey 150 + var conditions []string 151 + var args []any 152 + for _, filter := range filters { 153 + conditions = append(conditions, filter.Condition()) 154 + args = append(args, filter.Arg()...) 155 + } 156 + 157 + whereClause := "" 158 + if conditions != nil { 159 + whereClause = " where " + strings.Join(conditions, " and ") 160 + } 161 + limitClause := "" 162 + if limit > 0 { 163 + limitClause = " limit ?" 164 + args = append(args, limit) 165 + } 166 + 167 + query := fmt.Sprintf( 168 + `select user_did, subject_did, followed_at, rkey 105 169 from follows 170 + %s 106 171 order by followed_at desc 107 - limit ?`, limit, 108 - ) 172 + %s 173 + `, whereClause, limitClause) 174 + 175 + rows, err := e.Query(query, args...) 109 176 if err != nil { 110 177 return nil, err 111 178 } 112 - defer rows.Close() 113 - 114 179 for rows.Next() { 115 180 var follow Follow 116 181 var followedAt string 117 - if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 182 + err := rows.Scan( 183 + &follow.UserDid, 184 + &follow.SubjectDid, 185 + &followedAt, 186 + &follow.Rkey, 187 + ) 188 + if err != nil { 118 189 return nil, err 119 190 } 120 - 121 191 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 122 192 if err != nil { 123 193 log.Println("unable to determine followed at time") ··· 125 195 } else { 126 196 follow.FollowedAt = followedAtTime 127 197 } 128 - 129 198 follows = append(follows, follow) 130 199 } 200 + return follows, nil 201 + } 202 + 203 + func GetFollowers(e Execer, did string) ([]Follow, error) { 204 + return GetFollows(e, 0, FilterEq("subject_did", did)) 205 + } 131 206 132 - if err := rows.Err(); err != nil { 133 - return nil, err 207 + func GetFollowing(e Execer, did string) ([]Follow, error) { 208 + return GetFollows(e, 0, FilterEq("user_did", did)) 209 + } 210 + 211 + type FollowStatus int 212 + 213 + const ( 214 + IsNotFollowing FollowStatus = iota 215 + IsFollowing 216 + IsSelf 217 + ) 218 + 219 + func (s FollowStatus) String() string { 220 + switch s { 221 + case IsNotFollowing: 222 + return "IsNotFollowing" 223 + case IsFollowing: 224 + return "IsFollowing" 225 + case IsSelf: 226 + return "IsSelf" 227 + default: 228 + return "IsNotFollowing" 134 229 } 230 + } 135 231 136 - return follows, nil 232 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 + if userDid == subjectDid { 234 + return IsSelf 235 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 + return IsNotFollowing 237 + } else { 238 + return IsFollowing 239 + } 137 240 }
+208 -17
appview/db/issues.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + mathrand "math/rand/v2" 7 + "strings" 5 8 "time" 6 9 7 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.sh/tangled.sh/core/api/tangled" 8 12 "tangled.sh/tangled.sh/core/appview/pagination" 9 13 ) 10 14 ··· 13 17 RepoAt syntax.ATURI 14 18 OwnerDid string 15 19 IssueId int 16 - IssueAt string 20 + Rkey string 17 21 Created time.Time 18 22 Title string 19 23 Body string ··· 42 46 Edited *time.Time 43 47 } 44 48 49 + func (i *Issue) AtUri() syntax.ATURI { 50 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 + } 52 + 53 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 + if err != nil { 56 + created = time.Now() 57 + } 58 + 59 + body := "" 60 + if record.Body != nil { 61 + body = *record.Body 62 + } 63 + 64 + return Issue{ 65 + RepoAt: syntax.ATURI(record.Repo), 66 + OwnerDid: did, 67 + Rkey: rkey, 68 + Created: created, 69 + Title: record.Title, 70 + Body: body, 71 + Open: true, // new issues are open by default 72 + } 73 + } 74 + 75 + func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 + ownerDid := issueUri.Authority().String() 77 + issueRkey := issueUri.RecordKey().String() 78 + 79 + var repoAt string 80 + var issueId int 81 + 82 + query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 + err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 + if err != nil { 85 + return "", 0, err 86 + } 87 + 88 + return syntax.ATURI(repoAt), issueId, nil 89 + } 90 + 91 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + ownerDid := did 98 + if record.Owner != nil { 99 + ownerDid = *record.Owner 100 + } 101 + 102 + issueUri, err := syntax.ParseATURI(record.Issue) 103 + if err != nil { 104 + return Comment{}, err 105 + } 106 + 107 + repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 + if err != nil { 109 + return Comment{}, err 110 + } 111 + 112 + comment := Comment{ 113 + OwnerDid: ownerDid, 114 + RepoAt: repoAt, 115 + Rkey: rkey, 116 + Body: record.Body, 117 + Issue: issueId, 118 + CommentId: mathrand.IntN(1000000), 119 + Created: &created, 120 + } 121 + 122 + return comment, nil 123 + } 124 + 45 125 func NewIssue(tx *sql.Tx, issue *Issue) error { 46 126 defer tx.Rollback() 47 127 ··· 67 147 issue.IssueId = nextId 68 148 69 149 res, err := tx.Exec(` 70 - insert into issues (repo_at, owner_did, issue_id, title, body) 71 - values (?, ?, ?, ?, ?) 72 - `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 150 + insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 151 + values (?, ?, ?, ?, ?, ?, ?) 152 + `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 73 153 if err != nil { 74 154 return err 75 155 } ··· 87 167 return nil 88 168 } 89 169 90 - func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error { 91 - _, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId) 92 - return err 93 - } 94 - 95 170 func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 96 171 var issueAt string 97 172 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) ··· 104 179 return ownerDid, err 105 180 } 106 181 107 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 182 + func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 108 183 var issues []Issue 109 184 openValue := 0 110 185 if isOpen { ··· 117 192 select 118 193 i.id, 119 194 i.owner_did, 195 + i.rkey, 120 196 i.issue_id, 121 197 i.created, 122 198 i.title, ··· 136 212 select 137 213 id, 138 214 owner_did, 215 + rkey, 139 216 issue_id, 140 217 created, 141 218 title, 142 219 body, 143 220 open, 144 221 comment_count 145 - from 222 + from 146 223 numbered_issue 147 - where 224 + where 148 225 row_num between ? and ?`, 149 226 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 150 227 if err != nil { ··· 156 233 var issue Issue 157 234 var createdAt string 158 235 var metadata IssueMetadata 159 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 236 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 237 if err != nil { 161 238 return nil, err 162 239 } ··· 178 255 return issues, nil 179 256 } 180 257 258 + func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 + issues := make([]Issue, 0, limit) 260 + 261 + var conditions []string 262 + var args []any 263 + for _, filter := range filters { 264 + conditions = append(conditions, filter.Condition()) 265 + args = append(args, filter.Arg()...) 266 + } 267 + 268 + whereClause := "" 269 + if conditions != nil { 270 + whereClause = " where " + strings.Join(conditions, " and ") 271 + } 272 + limitClause := "" 273 + if limit != 0 { 274 + limitClause = fmt.Sprintf(" limit %d ", limit) 275 + } 276 + 277 + query := fmt.Sprintf( 278 + `select 279 + i.id, 280 + i.owner_did, 281 + i.repo_at, 282 + i.issue_id, 283 + i.created, 284 + i.title, 285 + i.body, 286 + i.open 287 + from 288 + issues i 289 + %s 290 + order by 291 + i.created desc 292 + %s`, 293 + whereClause, limitClause) 294 + 295 + rows, err := e.Query(query, args...) 296 + if err != nil { 297 + return nil, err 298 + } 299 + defer rows.Close() 300 + 301 + for rows.Next() { 302 + var issue Issue 303 + var issueCreatedAt string 304 + err := rows.Scan( 305 + &issue.ID, 306 + &issue.OwnerDid, 307 + &issue.RepoAt, 308 + &issue.IssueId, 309 + &issueCreatedAt, 310 + &issue.Title, 311 + &issue.Body, 312 + &issue.Open, 313 + ) 314 + if err != nil { 315 + return nil, err 316 + } 317 + 318 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 + if err != nil { 320 + return nil, err 321 + } 322 + issue.Created = issueCreatedTime 323 + 324 + issues = append(issues, issue) 325 + } 326 + 327 + if err := rows.Err(); err != nil { 328 + return nil, err 329 + } 330 + 331 + return issues, nil 332 + } 333 + 334 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 + return GetIssuesWithLimit(e, 0, filters...) 336 + } 337 + 181 338 // timeframe here is directly passed into the sql query filter, and any 182 339 // timeframe in the past should be negative; e.g.: "-3 months" 183 340 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 187 344 `select 188 345 i.id, 189 346 i.owner_did, 347 + i.rkey, 190 348 i.repo_at, 191 349 i.issue_id, 192 350 i.created, ··· 219 377 err := rows.Scan( 220 378 &issue.ID, 221 379 &issue.OwnerDid, 380 + &issue.Rkey, 222 381 &issue.RepoAt, 223 382 &issue.IssueId, 224 383 &issueCreatedAt, ··· 262 421 } 263 422 264 423 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 265 - query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 424 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 425 row := e.QueryRow(query, repoAt, issueId) 267 426 268 427 var issue Issue 269 428 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 429 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 430 if err != nil { 272 431 return nil, err 273 432 } ··· 282 441 } 283 442 284 443 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 285 - query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 444 + query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 286 445 row := e.QueryRow(query, repoAt, issueId) 287 446 288 447 var issue Issue 289 448 var createdAt string 290 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 449 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 291 450 if err != nil { 292 451 return nil, nil, err 293 452 } ··· 464 623 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 465 624 where repo_at = ? and issue_id = ? and comment_id = ? 466 625 `, repoAt, issueId, commentId) 626 + return err 627 + } 628 + 629 + func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 + _, err := e.Exec( 631 + ` 632 + update comments 633 + set body = ?, 634 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 + where owner_did = ? and rkey = ? 636 + `, newBody, ownerDid, rkey) 637 + return err 638 + } 639 + 640 + func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 + _, err := e.Exec( 642 + ` 643 + update comments 644 + set body = "", 645 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 + where owner_did = ? and rkey = ? 647 + `, ownerDid, rkey) 648 + return err 649 + } 650 + 651 + func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 + _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 + return err 654 + } 655 + 656 + func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 + _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 467 658 return err 468 659 } 469 660
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+16 -7
appview/db/profile.go
··· 22 22 ByMonth []ByMonth 23 23 } 24 24 25 + func (p *ProfileTimeline) IsEmpty() bool { 26 + if p == nil { 27 + return true 28 + } 29 + 30 + for _, m := range p.ByMonth { 31 + if !m.IsEmpty() { 32 + return false 33 + } 34 + } 35 + 36 + return true 37 + } 38 + 25 39 type ByMonth struct { 26 40 RepoEvents []RepoEvent 27 41 IssueEvents IssueEvents ··· 348 362 return tx.Commit() 349 363 } 350 364 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 365 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 352 366 var conditions []string 353 367 var args []any 354 368 for _, filter := range filters { ··· 448 462 idxs[did] = idx + 1 449 463 } 450 464 451 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 465 + return profileMap, nil 457 466 } 458 467 459 468 func GetProfile(e Execer, did string) (*Profile, error) {
+31 -11
appview/db/pulls.go
··· 91 91 } 92 92 93 93 record := tangled.RepoPull{ 94 - Title: p.Title, 95 - Body: &p.Body, 96 - CreatedAt: p.Created.Format(time.RFC3339), 97 - PullId: int64(p.PullId), 98 - TargetRepo: p.RepoAt.String(), 99 - TargetBranch: p.TargetBranch, 100 - Patch: p.LatestPatch(), 101 - Source: source, 94 + Title: p.Title, 95 + Body: &p.Body, 96 + CreatedAt: p.Created.Format(time.RFC3339), 97 + Target: &tangled.RepoPull_Target{ 98 + Repo: p.RepoAt.String(), 99 + Branch: p.TargetBranch, 100 + }, 101 + Patch: p.LatestPatch(), 102 + Source: source, 102 103 } 103 104 return record 104 105 } ··· 310 311 return pullId - 1, err 311 312 } 312 313 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 314 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 314 315 pulls := make(map[int]*Pull) 315 316 316 317 var conditions []string ··· 324 325 if conditions != nil { 325 326 whereClause = " where " + strings.Join(conditions, " and ") 326 327 } 328 + limitClause := "" 329 + if limit != 0 { 330 + limitClause = fmt.Sprintf(" limit %d ", limit) 331 + } 327 332 328 333 query := fmt.Sprintf(` 329 334 select ··· 344 349 from 345 350 pulls 346 351 %s 347 - `, whereClause) 352 + order by 353 + created desc 354 + %s 355 + `, whereClause, limitClause) 348 356 349 357 rows, err := e.Query(query, args...) 350 358 if err != nil { ··· 412 420 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 421 submissionsQuery := fmt.Sprintf(` 414 422 select 415 - id, pull_id, round_number, patch, source_rev 423 + id, pull_id, round_number, patch, created, source_rev 416 424 from 417 425 pull_submissions 418 426 where ··· 438 446 for submissionsRows.Next() { 439 447 var s PullSubmission 440 448 var sourceRev sql.NullString 449 + var createdAt string 441 450 err := submissionsRows.Scan( 442 451 &s.ID, 443 452 &s.PullId, 444 453 &s.RoundNumber, 445 454 &s.Patch, 455 + &createdAt, 446 456 &sourceRev, 447 457 ) 448 458 if err != nil { 449 459 return nil, err 450 460 } 451 461 462 + createdTime, err := time.Parse(time.RFC3339, createdAt) 463 + if err != nil { 464 + return nil, err 465 + } 466 + s.Created = createdTime 467 + 452 468 if sourceRev.Valid { 453 469 s.SourceRev = sourceRev.String 454 470 } ··· 511 527 }) 512 528 513 529 return orderedByPullId, nil 530 + } 531 + 532 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 533 + return GetPullsWithLimit(e, 0, filters...) 514 534 } 515 535 516 536 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+4 -4
appview/db/punchcard.go
··· 29 29 Punches []Punch 30 30 } 31 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 34 now := time.Now() 35 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 63 64 64 rows, err := e.Query(query, args...) 65 65 if err != nil { 66 - return punchcard, err 66 + return nil, err 67 67 } 68 68 defer rows.Close() 69 69 ··· 72 72 var date string 73 73 var count sql.NullInt64 74 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 75 + return nil, err 76 76 } 77 77 78 78 punch.Date, err = time.Parse(time.DateOnly, date)
+7 -7
appview/db/reaction.go
··· 11 11 12 12 const ( 13 13 Like ReactionKind = "๐Ÿ‘" 14 - Unlike = "๐Ÿ‘Ž" 15 - Laugh = "๐Ÿ˜†" 16 - Celebration = "๐ŸŽ‰" 17 - Confused = "๐Ÿซค" 18 - Heart = "โค๏ธ" 19 - Rocket = "๐Ÿš€" 20 - Eyes = "๐Ÿ‘€" 14 + Unlike ReactionKind = "๐Ÿ‘Ž" 15 + Laugh ReactionKind = "๐Ÿ˜†" 16 + Celebration ReactionKind = "๐ŸŽ‰" 17 + Confused ReactionKind = "๐Ÿซค" 18 + Heart ReactionKind = "โค๏ธ" 19 + Rocket ReactionKind = "๐Ÿš€" 20 + Eyes ReactionKind = "๐Ÿ‘€" 21 21 ) 22 22 23 23 func (rk ReactionKind) String() string {
+89 -125
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/rand" 5 4 "database/sql" 6 - "encoding/hex" 7 5 "fmt" 8 - "log" 6 + "strings" 9 7 "time" 10 8 ) 11 9 10 + // Registration represents a knot registration. Knot would've been a better 11 + // name but we're stuck with this for historical reasons. 12 12 type Registration struct { 13 13 Id int64 14 14 Domain string 15 15 ByDid string 16 16 Created *time.Time 17 17 Registered *time.Time 18 + ReadOnly bool 18 19 } 19 20 20 21 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 + if r.ReadOnly { 23 + return ReadOnly 24 + } else if r.Registered != nil { 22 25 return Registered 23 26 } else { 24 27 return Pending 25 28 } 26 29 } 27 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsReadOnly() bool { 36 + return r.Status() == ReadOnly 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 28 43 type Status uint32 29 44 30 45 const ( 31 46 Registered Status = iota 32 47 Pending 48 + ReadOnly 33 49 ) 34 50 35 - // returns registered status, did of owner, error 36 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 37 52 var registrations []Registration 38 53 39 - rows, err := e.Query(` 40 - select id, domain, did, created, registered from registrations 41 - where did = ? 42 - `, did) 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 59 + } 60 + 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 64 + } 65 + 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, read_only 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 74 + 75 + rows, err := e.Query(query, args...) 43 76 if err != nil { 44 77 return nil, err 45 78 } 46 79 47 80 for rows.Next() { 48 - var createdAt *string 49 - var registeredAt *string 50 - var registration Registration 51 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var readOnly int 84 + var reg Registration 52 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 53 87 if err != nil { 54 - log.Println(err) 55 - } else { 56 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 57 - var registeredAtTime *time.Time 58 - if registeredAt != nil { 59 - x, _ := time.Parse(time.RFC3339, *registeredAt) 60 - registeredAtTime = &x 61 - } 88 + return nil, err 89 + } 62 90 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 66 93 } 67 - } 68 94 69 - return registrations, nil 70 - } 71 - 72 - // returns registered status, did of owner, error 73 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 74 - var createdAt *string 75 - var registeredAt *string 76 - var registration Registration 77 - 78 - err := e.QueryRow(` 79 - select id, domain, did, created, registered from registrations 80 - where domain = ? 81 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 82 100 83 - if err != nil { 84 - if err == sql.ErrNoRows { 85 - return nil, nil 86 - } else { 87 - return nil, err 101 + if readOnly != 0 { 102 + reg.ReadOnly = true 88 103 } 89 - } 90 104 91 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 92 - var registeredAtTime *time.Time 93 - if registeredAt != nil { 94 - x, _ := time.Parse(time.RFC3339, *registeredAt) 95 - registeredAtTime = &x 105 + registrations = append(registrations, reg) 96 106 } 97 107 98 - registration.Created = &createdAtTime 99 - registration.Registered = registeredAtTime 100 - 101 - return &registration, nil 102 - } 103 - 104 - func genSecret() string { 105 - key := make([]byte, 32) 106 - rand.Read(key) 107 - return hex.EncodeToString(key) 108 + return registrations, nil 108 109 } 109 110 110 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 111 - // sanity check: does this domain already have a registration? 112 - reg, err := RegistrationByDomain(e, domain) 113 - if err != nil { 114 - return "", err 115 - } 116 - 117 - // registration is open 118 - if reg != nil { 119 - switch reg.Status() { 120 - case Registered: 121 - // already registered by `owner` 122 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 123 - case Pending: 124 - // TODO: be loud about this 125 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 126 - } 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 127 117 } 128 118 129 - secret := genSecret() 130 - 131 - _, err = e.Exec(` 132 - insert into registrations (domain, did, secret) 133 - values (?, ?, ?) 134 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 135 - `, domain, did, secret) 136 - 137 - if err != nil { 138 - return "", err 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 139 122 } 140 123 141 - return secret, nil 124 + _, err := e.Exec(query, args...) 125 + return err 142 126 } 143 127 144 - func GetRegistrationKey(e Execer, domain string) (string, error) { 145 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 146 - 147 - var secret string 148 - err := res.Scan(&secret) 149 - if err != nil || secret == "" { 150 - return "", err 151 - } 152 - 153 - return secret, nil 128 + func AddKnot(e Execer, domain, did string) error { 129 + _, err := e.Exec(` 130 + insert into registrations (domain, did) 131 + values (?, ?) 132 + `, domain, did) 133 + return err 154 134 } 155 135 156 - func GetCompletedRegistrations(e Execer) ([]string, error) { 157 - rows, err := e.Query(`select domain from registrations where registered not null`) 158 - if err != nil { 159 - return nil, err 136 + func DeleteKnot(e Execer, filters ...filter) error { 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 160 142 } 161 143 162 - var domains []string 163 - for rows.Next() { 164 - var domain string 165 - err = rows.Scan(&domain) 166 - 167 - if err != nil { 168 - log.Println(err) 169 - } else { 170 - domains = append(domains, domain) 171 - } 172 - } 173 - 174 - if err = rows.Err(); err != nil { 175 - return nil, err 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 176 147 } 177 148 178 - return domains, nil 179 - } 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 180 150 181 - func Register(e Execer, domain string) error { 182 - _, err := e.Exec(` 183 - update registrations 184 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 185 - where domain = ?; 186 - `, domain) 187 - 151 + _, err := e.Exec(query, args...) 188 152 return err 189 153 }
+44 -81
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 "fmt" 6 7 "log" 7 8 "slices" ··· 19 20 Knot string 20 21 Rkey string 21 22 Created time.Time 22 - AtUri string 23 23 Description string 24 24 Spindle string 25 25 ··· 37 37 func (r Repo) DidSlashRepo() string { 38 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 39 39 return p 40 - } 41 - 42 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 43 - var repos []Repo 44 - 45 - rows, err := e.Query( 46 - `select did, name, knot, rkey, description, created, source 47 - from repos 48 - order by created desc 49 - limit ? 50 - `, 51 - limit, 52 - ) 53 - if err != nil { 54 - return nil, err 55 - } 56 - defer rows.Close() 57 - 58 - for rows.Next() { 59 - var repo Repo 60 - err := scanRepo( 61 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 62 - ) 63 - if err != nil { 64 - return nil, err 65 - } 66 - repos = append(repos, repo) 67 - } 68 - 69 - if err := rows.Err(); err != nil { 70 - return nil, err 71 - } 72 - 73 - return repos, nil 74 40 } 75 41 76 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 311 277 312 278 slices.SortFunc(repos, func(a, b Repo) int { 313 279 if a.Created.After(b.Created) { 314 - return 1 280 + return -1 315 281 } 316 - return -1 282 + return 1 317 283 }) 318 284 319 285 return repos, nil 320 286 } 321 287 288 + func CountRepos(e Execer, filters ...filter) (int64, error) { 289 + var conditions []string 290 + var args []any 291 + for _, filter := range filters { 292 + conditions = append(conditions, filter.Condition()) 293 + args = append(args, filter.Arg()...) 294 + } 295 + 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 299 + } 300 + 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 304 + 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 307 + } 308 + 309 + return count, nil 310 + } 311 + 322 312 func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 323 313 var repos []Repo 324 314 ··· 391 381 var description, spindle sql.NullString 392 382 393 383 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 384 + select did, name, knot, created, description, spindle, rkey 395 385 from repos 396 386 where did = ? and name = ? 397 387 `, ··· 400 390 ) 401 391 402 392 var createdAt string 403 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 393 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 404 394 return nil, err 405 395 } 406 396 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 421 411 var repo Repo 422 412 var nullableDescription sql.NullString 423 413 424 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 414 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 425 415 426 416 var createdAt string 427 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 417 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 428 418 return nil, err 429 419 } 430 420 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 444 434 `insert into repos 445 435 (did, name, knot, rkey, at_uri, description, source) 446 436 values (?, ?, ?, ?, ?, ?, ?)`, 447 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 437 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 448 438 ) 449 439 return err 450 440 } ··· 467 457 var repos []Repo 468 458 469 459 rows, err := e.Query( 470 - `select did, name, knot, rkey, description, created, at_uri, source 471 - from repos 472 - where did = ? and source is not null and source != '' 473 - order by created desc`, 474 - did, 460 + `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 461 + from repos r 462 + left join collaborators c on r.at_uri = c.repo_at 463 + where (r.did = ? or c.subject_did = ?) 464 + and r.source is not null 465 + and r.source != '' 466 + order by r.created desc`, 467 + did, did, 475 468 ) 476 469 if err != nil { 477 470 return nil, err ··· 484 477 var nullableDescription sql.NullString 485 478 var nullableSource sql.NullString 486 479 487 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 480 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 488 481 if err != nil { 489 482 return nil, err 490 483 } ··· 521 514 var nullableSource sql.NullString 522 515 523 516 row := e.QueryRow( 524 - `select did, name, knot, rkey, description, created, at_uri, source 517 + `select did, name, knot, rkey, description, created, source 525 518 from repos 526 519 where did = ? and name = ? and source is not null and source != ''`, 527 520 did, name, 528 521 ) 529 522 530 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 523 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 531 524 if err != nil { 532 525 return nil, err 533 526 } ··· 556 549 return err 557 550 } 558 551 559 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 552 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 560 553 _, err := e.Exec( 561 554 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 562 555 return err ··· 568 561 IssueCount IssueCount 569 562 PullCount PullCount 570 563 } 571 - 572 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 573 - var createdAt string 574 - var nullableDescription sql.NullString 575 - var nullableSource sql.NullString 576 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 577 - return err 578 - } 579 - 580 - if nullableDescription.Valid { 581 - *description = nullableDescription.String 582 - } else { 583 - *description = "" 584 - } 585 - 586 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 587 - if err != nil { 588 - *created = time.Now() 589 - } else { 590 - *created = createdAtTime 591 - } 592 - 593 - if nullableSource.Valid { 594 - *source = nullableSource.String 595 - } else { 596 - *source = "" 597 - } 598 - 599 - return nil 600 - }
+100 -6
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 5 + "errors" 4 6 "fmt" 5 7 "log" 6 8 "strings" ··· 47 49 // Get a star record 48 50 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 51 query := ` 50 - select starred_by_did, repo_at, created, rkey 52 + select starred_by_did, repo_at, created, rkey 51 53 from stars 52 54 where starred_by_did = ? and repo_at = ?` 53 55 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 121 } 120 122 121 123 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 124 + `select starred_by_did, repo_at, created, rkey 123 125 from stars 124 126 %s 125 127 order by created desc ··· 183 185 return stars, nil 184 186 } 185 187 188 + func CountStars(e Execer, filters ...filter) (int64, error) { 189 + var conditions []string 190 + var args []any 191 + for _, filter := range filters { 192 + conditions = append(conditions, filter.Condition()) 193 + args = append(args, filter.Arg()...) 194 + } 195 + 196 + whereClause := "" 197 + if conditions != nil { 198 + whereClause = " where " + strings.Join(conditions, " and ") 199 + } 200 + 201 + repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause) 202 + var count int64 203 + err := e.QueryRow(repoQuery, args...).Scan(&count) 204 + 205 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 206 + return 0, err 207 + } 208 + 209 + return count, nil 210 + } 211 + 186 212 func GetAllStars(e Execer, limit int) ([]Star, error) { 187 213 var stars []Star 188 214 189 215 rows, err := e.Query(` 190 - select 216 + select 191 217 s.starred_by_did, 192 218 s.repo_at, 193 219 s.rkey, ··· 196 222 r.name, 197 223 r.knot, 198 224 r.rkey, 199 - r.created, 200 - r.at_uri 225 + r.created 201 226 from stars s 202 227 join repos r on s.repo_at = r.at_uri 203 228 `) ··· 222 247 &repo.Knot, 223 248 &repo.Rkey, 224 249 &repoCreatedAt, 225 - &repo.AtUri, 226 250 ); err != nil { 227 251 return nil, err 228 252 } ··· 246 270 247 271 return stars, nil 248 272 } 273 + 274 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 275 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 276 + // first, get the top repo URIs by star count from the last week 277 + query := ` 278 + with recent_starred_repos as ( 279 + select distinct repo_at 280 + from stars 281 + where created >= datetime('now', '-7 days') 282 + ), 283 + repo_star_counts as ( 284 + select 285 + s.repo_at, 286 + count(*) as stars_gained_last_week 287 + from stars s 288 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 289 + where s.created >= datetime('now', '-7 days') 290 + group by s.repo_at 291 + ) 292 + select rsc.repo_at 293 + from repo_star_counts rsc 294 + order by rsc.stars_gained_last_week desc 295 + limit 8 296 + ` 297 + 298 + rows, err := e.Query(query) 299 + if err != nil { 300 + return nil, err 301 + } 302 + defer rows.Close() 303 + 304 + var repoUris []string 305 + for rows.Next() { 306 + var repoUri string 307 + err := rows.Scan(&repoUri) 308 + if err != nil { 309 + return nil, err 310 + } 311 + repoUris = append(repoUris, repoUri) 312 + } 313 + 314 + if err := rows.Err(); err != nil { 315 + return nil, err 316 + } 317 + 318 + if len(repoUris) == 0 { 319 + return []Repo{}, nil 320 + } 321 + 322 + // get full repo data 323 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 324 + if err != nil { 325 + return nil, err 326 + } 327 + 328 + // sort repos by the original trending order 329 + repoMap := make(map[string]Repo) 330 + for _, repo := range repos { 331 + repoMap[repo.RepoAt().String()] = repo 332 + } 333 + 334 + orderedRepos := make([]Repo, 0, len(repoUris)) 335 + for _, uri := range repoUris { 336 + if repo, exists := repoMap[uri]; exists { 337 + orderedRepos = append(orderedRepos, repo) 338 + } 339 + } 340 + 341 + return orderedRepos, nil 342 + }
+36 -11
appview/db/strings.go
··· 50 50 func (s String) Validate() error { 51 51 var err error 52 52 53 - if !strings.Contains(s.Filename, ".") { 54 - err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 - } 56 - 57 - if strings.HasSuffix(s.Filename, ".") { 58 - err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 - } 60 - 61 53 if utf8.RuneCountInString(s.Filename) > 140 { 62 54 err = errors.Join(err, fmt.Errorf("filename too long")) 63 55 } ··· 113 105 filename = excluded.filename, 114 106 description = excluded.description, 115 107 content = excluded.content, 116 - edited = case 108 + edited = case 117 109 when 118 110 strings.content != excluded.content 119 111 or strings.filename != excluded.filename ··· 131 123 return err 132 124 } 133 125 134 - func GetStrings(e Execer, filters ...filter) ([]String, error) { 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 135 127 var all []String 136 128 137 129 var conditions []string ··· 146 138 whereClause = " where " + strings.Join(conditions, " and ") 147 139 } 148 140 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 149 146 query := fmt.Sprintf(`select 150 147 did, 151 148 rkey, ··· 154 151 content, 155 152 created, 156 153 edited 157 - from strings %s`, 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 158 whereClause, 159 + limitClause, 159 160 ) 160 161 161 162 rows, err := e.Query(query, args...) ··· 203 204 } 204 205 205 206 return all, nil 207 + } 208 + 209 + func CountStrings(e Execer, filters ...filter) (int64, error) { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause) 223 + var count int64 224 + err := e.QueryRow(repoQuery, args...).Scan(&count) 225 + 226 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 227 + return 0, err 228 + } 229 + 230 + return count, nil 206 231 } 207 232 208 233 func DeleteString(e Execer, filters ...filter) error {
+6 -22
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 - type FollowStats struct { 24 - Followers int 25 - Following int 26 - } 27 - 28 23 const Limit = 50 29 24 30 25 // TODO: this gathers heterogenous events from different sources and aggregates ··· 137 132 } 138 133 139 134 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 - follows, err := GetAllFollows(e, Limit) 135 + follows, err := GetFollows(e, Limit) 141 136 if err != nil { 142 137 return nil, err 143 138 } ··· 151 146 return nil, nil 152 147 } 153 148 154 - profileMap := make(map[string]Profile) 155 149 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 150 if err != nil { 157 151 return nil, err 158 152 } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 - } 162 153 163 - followStatMap := make(map[string]FollowStats) 164 - for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowing(e, s) 166 - if err != nil { 167 - return nil, err 168 - } 169 - followStatMap[s] = FollowStats{ 170 - Followers: followers, 171 - Following: following, 172 - } 154 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 155 + if err != nil { 156 + return nil, err 173 157 } 174 158 175 159 var events []TimelineEvent 176 160 for _, f := range follows { 177 - profile, _ := profileMap[f.SubjectDid] 161 + profile, _ := profiles[f.SubjectDid] 178 162 followStatMap, _ := followStatMap[f.SubjectDid] 179 163 180 164 events = append(events, TimelineEvent{ 181 165 Follow: &f, 182 - Profile: &profile, 166 + Profile: profile, 183 167 FollowStats: &followStatMap, 184 168 EventAt: f.FollowedAt, 185 169 })
+342 -10
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 15 "tangled.sh/tangled.sh/core/api/tangled" 15 16 "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 + "tangled.sh/tangled.sh/core/appview/serververify" 18 20 "tangled.sh/tangled.sh/core/idresolver" 19 21 "tangled.sh/tangled.sh/core/rbac" 20 22 ) ··· 61 63 case tangled.ActorProfileNSID: 62 64 err = i.ingestProfile(e) 63 65 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 66 + err = i.ingestSpindleMember(ctx, e) 65 67 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 68 + err = i.ingestSpindle(ctx, e) 69 + case tangled.KnotMemberNSID: 70 + err = i.ingestKnotMember(e) 71 + case tangled.KnotNSID: 72 + err = i.ingestKnot(e) 67 73 case tangled.StringNSID: 68 74 err = i.ingestString(e) 75 + case tangled.RepoIssueNSID: 76 + err = i.ingestIssue(ctx, e) 77 + case tangled.RepoIssueCommentNSID: 78 + err = i.ingestIssueComment(e) 69 79 } 70 80 l = i.Logger.With("nsid", e.Commit.Collection) 71 81 } 72 82 73 83 if err != nil { 74 - l.Error("error ingesting record", "err", err) 84 + l.Debug("error ingesting record", "err", err) 75 85 } 76 86 77 - return err 87 + return nil 78 88 } 79 89 } 80 90 ··· 336 346 return nil 337 347 } 338 348 339 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 349 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 340 350 did := e.Did 341 351 var err error 342 352 ··· 359 369 return fmt.Errorf("failed to enforce permissions: %w", err) 360 370 } 361 371 362 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 372 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 363 373 if err != nil { 364 374 return err 365 375 } ··· 442 452 return nil 443 453 } 444 454 445 - func (i *Ingester) ingestSpindle(e *models.Event) error { 455 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 446 456 did := e.Did 447 457 var err error 448 458 ··· 475 485 return err 476 486 } 477 487 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 488 + err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 479 489 if err != nil { 480 490 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 491 return err 482 492 } 483 493 484 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 494 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 485 495 if err != nil { 486 496 return fmt.Errorf("failed to mark verified: %w", err) 487 497 } ··· 609 619 610 620 return nil 611 621 } 622 + 623 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 624 + did := e.Did 625 + var err error 626 + 627 + l := i.Logger.With("handler", "ingestKnotMember") 628 + l = l.With("nsid", e.Commit.Collection) 629 + 630 + switch e.Commit.Operation { 631 + case models.CommitOperationCreate: 632 + raw := json.RawMessage(e.Commit.Record) 633 + record := tangled.KnotMember{} 634 + err = json.Unmarshal(raw, &record) 635 + if err != nil { 636 + l.Error("invalid record", "err", err) 637 + return err 638 + } 639 + 640 + // only knot owner can invite to knots 641 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 642 + if err != nil || !ok { 643 + return fmt.Errorf("failed to enforce permissions: %w", err) 644 + } 645 + 646 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 647 + if err != nil { 648 + return err 649 + } 650 + 651 + if memberId.Handle.IsInvalidHandle() { 652 + return err 653 + } 654 + 655 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 656 + if err != nil { 657 + return fmt.Errorf("failed to update ACLs: %w", err) 658 + } 659 + 660 + l.Info("added knot member") 661 + case models.CommitOperationDelete: 662 + // we don't store knot members in a table (like we do for spindle) 663 + // and we can't remove this just yet. possibly fixed if we switch 664 + // to either: 665 + // 1. a knot_members table like with spindle and store the rkey 666 + // 2. use the knot host as the rkey 667 + // 668 + // TODO: implement member deletion 669 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 670 + } 671 + 672 + return nil 673 + } 674 + 675 + func (i *Ingester) ingestKnot(e *models.Event) error { 676 + did := e.Did 677 + var err error 678 + 679 + l := i.Logger.With("handler", "ingestKnot") 680 + l = l.With("nsid", e.Commit.Collection) 681 + 682 + switch e.Commit.Operation { 683 + case models.CommitOperationCreate: 684 + raw := json.RawMessage(e.Commit.Record) 685 + record := tangled.Knot{} 686 + err = json.Unmarshal(raw, &record) 687 + if err != nil { 688 + l.Error("invalid record", "err", err) 689 + return err 690 + } 691 + 692 + domain := e.Commit.RKey 693 + 694 + ddb, ok := i.Db.Execer.(*db.DB) 695 + if !ok { 696 + return fmt.Errorf("failed to index profile record, invalid db cast") 697 + } 698 + 699 + err := db.AddKnot(ddb, domain, did) 700 + if err != nil { 701 + l.Error("failed to add knot to db", "err", err, "domain", domain) 702 + return err 703 + } 704 + 705 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 706 + if err != nil { 707 + l.Error("failed to verify knot", "err", err, "domain", domain) 708 + return err 709 + } 710 + 711 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 712 + if err != nil { 713 + return fmt.Errorf("failed to mark verified: %w", err) 714 + } 715 + 716 + return nil 717 + 718 + case models.CommitOperationDelete: 719 + domain := e.Commit.RKey 720 + 721 + ddb, ok := i.Db.Execer.(*db.DB) 722 + if !ok { 723 + return fmt.Errorf("failed to index knot record, invalid db cast") 724 + } 725 + 726 + // get record from db first 727 + registrations, err := db.GetRegistrations( 728 + ddb, 729 + db.FilterEq("domain", domain), 730 + db.FilterEq("did", did), 731 + ) 732 + if err != nil { 733 + return fmt.Errorf("failed to get registration: %w", err) 734 + } 735 + if len(registrations) != 1 { 736 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 737 + } 738 + registration := registrations[0] 739 + 740 + tx, err := ddb.Begin() 741 + if err != nil { 742 + return err 743 + } 744 + defer func() { 745 + tx.Rollback() 746 + i.Enforcer.E.LoadPolicy() 747 + }() 748 + 749 + err = db.DeleteKnot( 750 + tx, 751 + db.FilterEq("did", did), 752 + db.FilterEq("domain", domain), 753 + ) 754 + if err != nil { 755 + return err 756 + } 757 + 758 + if registration.Registered != nil { 759 + err = i.Enforcer.RemoveKnot(domain) 760 + if err != nil { 761 + return err 762 + } 763 + } 764 + 765 + err = tx.Commit() 766 + if err != nil { 767 + return err 768 + } 769 + 770 + err = i.Enforcer.E.SavePolicy() 771 + if err != nil { 772 + return err 773 + } 774 + } 775 + 776 + return nil 777 + } 778 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 779 + did := e.Did 780 + rkey := e.Commit.RKey 781 + 782 + var err error 783 + 784 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 785 + l.Info("ingesting record") 786 + 787 + ddb, ok := i.Db.Execer.(*db.DB) 788 + if !ok { 789 + return fmt.Errorf("failed to index issue record, invalid db cast") 790 + } 791 + 792 + switch e.Commit.Operation { 793 + case models.CommitOperationCreate: 794 + raw := json.RawMessage(e.Commit.Record) 795 + record := tangled.RepoIssue{} 796 + err = json.Unmarshal(raw, &record) 797 + if err != nil { 798 + l.Error("invalid record", "err", err) 799 + return err 800 + } 801 + 802 + issue := db.IssueFromRecord(did, rkey, record) 803 + 804 + sanitizer := markup.NewSanitizer() 805 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 806 + return fmt.Errorf("title is empty after HTML sanitization") 807 + } 808 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 809 + return fmt.Errorf("body is empty after HTML sanitization") 810 + } 811 + 812 + tx, err := ddb.BeginTx(ctx, nil) 813 + if err != nil { 814 + l.Error("failed to begin transaction", "err", err) 815 + return err 816 + } 817 + 818 + err = db.NewIssue(tx, &issue) 819 + if err != nil { 820 + l.Error("failed to create issue", "err", err) 821 + return err 822 + } 823 + 824 + return nil 825 + 826 + case models.CommitOperationUpdate: 827 + raw := json.RawMessage(e.Commit.Record) 828 + record := tangled.RepoIssue{} 829 + err = json.Unmarshal(raw, &record) 830 + if err != nil { 831 + l.Error("invalid record", "err", err) 832 + return err 833 + } 834 + 835 + body := "" 836 + if record.Body != nil { 837 + body = *record.Body 838 + } 839 + 840 + sanitizer := markup.NewSanitizer() 841 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 842 + return fmt.Errorf("title is empty after HTML sanitization") 843 + } 844 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 845 + return fmt.Errorf("body is empty after HTML sanitization") 846 + } 847 + 848 + err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 849 + if err != nil { 850 + l.Error("failed to update issue", "err", err) 851 + return err 852 + } 853 + 854 + return nil 855 + 856 + case models.CommitOperationDelete: 857 + if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 858 + l.Error("failed to delete", "err", err) 859 + return fmt.Errorf("failed to delete issue record: %w", err) 860 + } 861 + 862 + return nil 863 + } 864 + 865 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 866 + } 867 + 868 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 869 + did := e.Did 870 + rkey := e.Commit.RKey 871 + 872 + var err error 873 + 874 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 875 + l.Info("ingesting record") 876 + 877 + ddb, ok := i.Db.Execer.(*db.DB) 878 + if !ok { 879 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 880 + } 881 + 882 + switch e.Commit.Operation { 883 + case models.CommitOperationCreate: 884 + raw := json.RawMessage(e.Commit.Record) 885 + record := tangled.RepoIssueComment{} 886 + err = json.Unmarshal(raw, &record) 887 + if err != nil { 888 + l.Error("invalid record", "err", err) 889 + return err 890 + } 891 + 892 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 893 + if err != nil { 894 + l.Error("failed to parse comment from record", "err", err) 895 + return err 896 + } 897 + 898 + sanitizer := markup.NewSanitizer() 899 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 900 + return fmt.Errorf("body is empty after HTML sanitization") 901 + } 902 + 903 + err = db.NewIssueComment(ddb, &comment) 904 + if err != nil { 905 + l.Error("failed to create issue comment", "err", err) 906 + return err 907 + } 908 + 909 + return nil 910 + 911 + case models.CommitOperationUpdate: 912 + raw := json.RawMessage(e.Commit.Record) 913 + record := tangled.RepoIssueComment{} 914 + err = json.Unmarshal(raw, &record) 915 + if err != nil { 916 + l.Error("invalid record", "err", err) 917 + return err 918 + } 919 + 920 + sanitizer := markup.NewSanitizer() 921 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 922 + return fmt.Errorf("body is empty after HTML sanitization") 923 + } 924 + 925 + err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 926 + if err != nil { 927 + l.Error("failed to update issue comment", "err", err) 928 + return err 929 + } 930 + 931 + return nil 932 + 933 + case models.CommitOperationDelete: 934 + if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 935 + l.Error("failed to delete", "err", err) 936 + return fmt.Errorf("failed to delete issue comment record: %w", err) 937 + } 938 + 939 + return nil 940 + } 941 + 942 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 943 + }
+40 -95
appview/issues/issues.go
··· 7 7 "net/http" 8 8 "slices" 9 9 "strconv" 10 + "strings" 10 11 "time" 11 12 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 14 "github.com/bluesky-social/indigo/atproto/data" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 17 ··· 21 21 "tangled.sh/tangled.sh/core/appview/notify" 22 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 23 "tangled.sh/tangled.sh/core/appview/pages" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 24 25 "tangled.sh/tangled.sh/core/appview/pagination" 25 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 27 "tangled.sh/tangled.sh/core/idresolver" ··· 73 74 return 74 75 } 75 76 76 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 77 + issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 77 78 if err != nil { 78 79 log.Println("failed to get issue and comments", err) 79 80 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 80 81 return 81 82 } 82 83 83 - reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 84 85 if err != nil { 85 86 log.Println("failed to get issue reactions") 86 87 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 88 89 89 90 userReactions := map[db.ReactionKind]bool{} 90 91 if user != nil { 91 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 92 93 } 93 94 94 95 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) ··· 96 97 log.Println("failed to resolve issue owner", err) 97 98 } 98 99 99 - identsToResolve := make([]string, len(comments)) 100 - for i, comment := range comments { 101 - identsToResolve[i] = comment.OwnerDid 102 - } 103 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 104 - didHandleMap := make(map[string]string) 105 - for _, identity := range resolvedIds { 106 - if !identity.Handle.IsInvalidHandle() { 107 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 108 - } else { 109 - didHandleMap[identity.DID.String()] = identity.DID.String() 110 - } 111 - } 112 - 113 100 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 114 101 LoggedInUser: user, 115 102 RepoInfo: f.RepoInfo(user), 116 - Issue: *issue, 103 + Issue: issue, 117 104 Comments: comments, 118 105 119 106 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 120 - DidHandleMap: didHandleMap, 121 107 122 108 OrderedReactionKinds: db.OrderedReactionKinds, 123 109 Reactions: reactionCountMap, ··· 142 128 return 143 129 } 144 130 145 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 131 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 146 132 if err != nil { 147 133 log.Println("failed to get issue", err) 148 134 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 174 160 Rkey: tid.TID(), 175 161 Record: &lexutil.LexiconTypeDecoder{ 176 162 Val: &tangled.RepoIssueState{ 177 - Issue: issue.IssueAt, 163 + Issue: issue.AtUri().String(), 178 164 State: closed, 179 165 }, 180 166 }, ··· 186 172 return 187 173 } 188 174 189 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 175 + err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 190 176 if err != nil { 191 177 log.Println("failed to close issue", err) 192 178 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 218 204 return 219 205 } 220 206 221 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 207 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 222 208 if err != nil { 223 209 log.Println("failed to get issue", err) 224 210 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 235 221 isIssueOwner := user.Did == issue.OwnerDid 236 222 237 223 if isCollaborator || isIssueOwner { 238 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 224 + err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 239 225 if err != nil { 240 226 log.Println("failed to reopen issue", err) 241 227 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") ··· 279 265 280 266 err := db.NewIssueComment(rp.db, &db.Comment{ 281 267 OwnerDid: user.Did, 282 - RepoAt: f.RepoAt, 268 + RepoAt: f.RepoAt(), 283 269 Issue: issueIdInt, 284 270 CommentId: commentId, 285 271 Body: body, ··· 292 278 } 293 279 294 280 createdAt := time.Now().Format(time.RFC3339) 295 - commentIdInt64 := int64(commentId) 296 281 ownerDid := user.Did 297 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 282 + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 298 283 if err != nil { 299 284 log.Println("failed to get issue at", err) 300 285 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 301 286 return 302 287 } 303 288 304 - atUri := f.RepoAt.String() 289 + atUri := f.RepoAt().String() 305 290 client, err := rp.oauth.AuthorizedClient(r) 306 291 if err != nil { 307 292 log.Println("failed to get authorized client", err) ··· 316 301 Val: &tangled.RepoIssueComment{ 317 302 Repo: &atUri, 318 303 Issue: issueAt, 319 - CommentId: &commentIdInt64, 320 304 Owner: &ownerDid, 321 305 Body: body, 322 306 CreatedAt: createdAt, ··· 358 342 return 359 343 } 360 344 361 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 345 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 362 346 if err != nil { 363 347 log.Println("failed to get issue", err) 364 348 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 365 349 return 366 350 } 367 351 368 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 352 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 369 353 if err != nil { 370 354 http.Error(w, "bad comment id", http.StatusBadRequest) 371 355 return 372 356 } 373 357 374 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 375 - if err != nil { 376 - log.Println("failed to resolve did") 377 - return 378 - } 379 - 380 - didHandleMap := make(map[string]string) 381 - if !identity.Handle.IsInvalidHandle() { 382 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 383 - } else { 384 - didHandleMap[identity.DID.String()] = identity.DID.String() 385 - } 386 - 387 358 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 388 359 LoggedInUser: user, 389 360 RepoInfo: f.RepoInfo(user), 390 - DidHandleMap: didHandleMap, 391 361 Issue: issue, 392 362 Comment: comment, 393 363 }) ··· 417 387 return 418 388 } 419 389 420 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 390 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 421 391 if err != nil { 422 392 log.Println("failed to get issue", err) 423 393 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 424 394 return 425 395 } 426 396 427 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 397 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 428 398 if err != nil { 429 399 http.Error(w, "bad comment id", http.StatusBadRequest) 430 400 return ··· 479 449 repoAt := record["repo"].(string) 480 450 issueAt := record["issue"].(string) 481 451 createdAt := record["createdAt"].(string) 482 - commentIdInt64 := int64(commentIdInt) 483 452 484 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 485 454 Collection: tangled.RepoIssueCommentNSID, ··· 490 459 Val: &tangled.RepoIssueComment{ 491 460 Repo: &repoAt, 492 461 Issue: issueAt, 493 - CommentId: &commentIdInt64, 494 462 Owner: &comment.OwnerDid, 495 463 Body: newBody, 496 464 CreatedAt: createdAt, ··· 503 471 } 504 472 505 473 // optimistic update for htmx 506 - didHandleMap := map[string]string{ 507 - user.Did: user.Handle, 508 - } 509 474 comment.Body = newBody 510 475 comment.Edited = &edited 511 476 ··· 513 478 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 514 479 LoggedInUser: user, 515 480 RepoInfo: f.RepoInfo(user), 516 - DidHandleMap: didHandleMap, 517 481 Issue: issue, 518 482 Comment: comment, 519 483 }) ··· 539 503 return 540 504 } 541 505 542 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 506 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 543 507 if err != nil { 544 508 log.Println("failed to get issue", err) 545 509 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 554 518 return 555 519 } 556 520 557 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 521 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 558 522 if err != nil { 559 523 http.Error(w, "bad comment id", http.StatusBadRequest) 560 524 return ··· 572 536 573 537 // optimistic deletion 574 538 deleted := time.Now() 575 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 539 + err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 576 540 if err != nil { 577 541 log.Println("failed to delete comment") 578 542 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 598 562 } 599 563 600 564 // optimistic update for htmx 601 - didHandleMap := map[string]string{ 602 - user.Did: user.Handle, 603 - } 604 565 comment.Body = "" 605 566 comment.Deleted = &deleted 606 567 ··· 608 569 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 609 570 LoggedInUser: user, 610 571 RepoInfo: f.RepoInfo(user), 611 - DidHandleMap: didHandleMap, 612 572 Issue: issue, 613 573 Comment: comment, 614 574 }) 615 - return 616 575 } 617 576 618 577 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 641 600 return 642 601 } 643 602 644 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 603 + issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 645 604 if err != nil { 646 605 log.Println("failed to get issues", err) 647 606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 648 607 return 649 608 } 650 609 651 - identsToResolve := make([]string, len(issues)) 652 - for i, issue := range issues { 653 - identsToResolve[i] = issue.OwnerDid 654 - } 655 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 656 - didHandleMap := make(map[string]string) 657 - for _, identity := range resolvedIds { 658 - if !identity.Handle.IsInvalidHandle() { 659 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 660 - } else { 661 - didHandleMap[identity.DID.String()] = identity.DID.String() 662 - } 663 - } 664 - 665 610 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 666 611 LoggedInUser: rp.oauth.GetUser(r), 667 612 RepoInfo: f.RepoInfo(user), 668 613 Issues: issues, 669 - DidHandleMap: didHandleMap, 670 614 FilteringByOpen: isOpen, 671 615 Page: page, 672 616 }) 673 - return 674 617 } 675 618 676 619 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { ··· 697 640 return 698 641 } 699 642 643 + sanitizer := markup.NewSanitizer() 644 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 + rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 646 + return 647 + } 648 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 + rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 650 + return 651 + } 652 + 700 653 tx, err := rp.db.BeginTx(r.Context(), nil) 701 654 if err != nil { 702 655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 704 657 } 705 658 706 659 issue := &db.Issue{ 707 - RepoAt: f.RepoAt, 660 + RepoAt: f.RepoAt(), 661 + Rkey: tid.TID(), 708 662 Title: title, 709 663 Body: body, 710 664 OwnerDid: user.Did, ··· 722 676 rp.pages.Notice(w, "issues", "Failed to create issue.") 723 677 return 724 678 } 725 - atUri := f.RepoAt.String() 726 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 679 + atUri := f.RepoAt().String() 680 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 681 Collection: tangled.RepoIssueNSID, 728 682 Repo: user.Did, 729 - Rkey: tid.TID(), 683 + Rkey: issue.Rkey, 730 684 Record: &lexutil.LexiconTypeDecoder{ 731 685 Val: &tangled.RepoIssue{ 732 - Repo: atUri, 733 - Title: title, 734 - Body: &body, 735 - Owner: user.Did, 736 - IssueId: int64(issue.IssueId), 686 + Repo: atUri, 687 + Title: title, 688 + Body: &body, 737 689 }, 738 690 }, 739 691 }) 740 692 if err != nil { 741 693 log.Println("failed to create issue", err) 742 - rp.pages.Notice(w, "issues", "Failed to create issue.") 743 - return 744 - } 745 - 746 - err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 747 - if err != nil { 748 - log.Println("failed to set issue at", err) 749 694 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 695 return 751 696 }
+443 -232
appview/knots/knots.go
··· 1 1 package knots 2 2 3 3 import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 4 + "errors" 8 5 "fmt" 6 + "log" 9 7 "log/slog" 10 8 "net/http" 11 - "strings" 9 + "slices" 12 10 "time" 13 11 14 12 "github.com/go-chi/chi/v5" ··· 18 16 "tangled.sh/tangled.sh/core/appview/middleware" 19 17 "tangled.sh/tangled.sh/core/appview/oauth" 20 18 "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/serververify" 21 20 "tangled.sh/tangled.sh/core/eventconsumer" 22 21 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 22 "tangled.sh/tangled.sh/core/rbac" 25 23 "tangled.sh/tangled.sh/core/tid" 26 24 ··· 39 37 Knotstream *eventconsumer.Consumer 40 38 } 41 39 42 - func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 40 + func (k *Knots) Router() http.Handler { 43 41 r := chi.NewRouter() 44 42 45 - r.Use(middleware.AuthMiddleware(k.OAuth)) 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 45 + 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 46 48 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 49 52 50 - r.Route("/{domain}", func(r chi.Router) { 51 - r.Post("/init", k.init) 52 - r.Get("/", k.dashboard) 53 - r.Route("/member", func(r chi.Router) { 54 - r.Use(mw.KnotOwner()) 55 - r.Get("/", k.members) 56 - r.Put("/", k.addMember) 57 - r.Delete("/", k.removeMember) 58 - }) 59 - }) 53 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 60 54 61 55 return r 62 56 } 63 57 64 - // get knots registered by this user 65 - func (k *Knots) index(w http.ResponseWriter, r *http.Request) { 66 - l := k.Logger.With("handler", "index") 67 - 58 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 68 59 user := k.OAuth.GetUser(r) 69 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 60 + registrations, err := db.GetRegistrations( 61 + k.Db, 62 + db.FilterEq("did", user.Did), 63 + ) 70 64 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 65 + k.Logger.Error("failed to fetch knot registrations", "err", err) 66 + w.WriteHeader(http.StatusInternalServerError) 67 + return 72 68 } 73 69 74 70 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 73 }) 78 74 } 79 75 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 76 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 77 + l := k.Logger.With("handler", "dashboard") 83 78 84 79 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 80 + l = l.With("user", user.Did) 87 81 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 82 + domain := chi.URLParam(r, "domain") 90 83 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 84 return 94 85 } 95 86 l = l.With("domain", domain) 96 87 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 88 + registrations, err := db.GetRegistrations( 89 + k.Db, 90 + db.FilterEq("did", user.Did), 91 + db.FilterEq("domain", domain), 92 + ) 93 + if err != nil { 94 + l.Error("failed to get registrations", "err", err) 95 + http.Error(w, "Not found", http.StatusNotFound) 96 + return 100 97 } 98 + if len(registrations) != 1 { 99 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 100 + return 101 + } 102 + registration := registrations[0] 101 103 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 104 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 105 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 + l.Error("failed to get knot members", "err", err) 107 + http.Error(w, "Not found", http.StatusInternalServerError) 106 108 return 107 109 } 110 + slices.Sort(members) 108 111 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 112 + repos, err := db.GetRepos( 113 + k.Db, 114 + 0, 115 + db.FilterEq("knot", domain), 116 + ) 110 117 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 118 + l.Error("failed to get knot repos", "err", err) 119 + http.Error(w, "Not found", http.StatusInternalServerError) 113 120 return 114 121 } 115 122 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 123 + // organize repos by did 124 + repoMap := make(map[string][]db.Repo) 125 + for _, r := range repos { 126 + repoMap[r.Did] = append(repoMap[r.Did], r) 127 + } 128 + 129 + k.Pages.Knot(w, pages.KnotParams{ 130 + LoggedInUser: user, 131 + Registration: &registration, 132 + Members: members, 133 + Repos: repoMap, 134 + IsOwner: true, 121 135 }) 122 136 } 123 137 124 - // create a signed request and check if a node responds to that 125 - func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 - l := k.Logger.With("handler", "init") 138 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 127 139 user := k.OAuth.GetUser(r) 140 + l := k.Logger.With("handler", "register") 128 141 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 142 + noticeId := "register-error" 143 + defaultErr := "Failed to register knot. Try again later." 131 144 fail := func() { 132 145 k.Pages.Notice(w, noticeId, defaultErr) 133 146 } 134 147 135 - domain := chi.URLParam(r, "domain") 148 + domain := r.FormValue("domain") 136 149 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 150 + k.Pages.Notice(w, noticeId, "Incomplete form.") 138 151 return 139 152 } 140 153 l = l.With("domain", domain) 154 + l = l.With("user", user.Did) 141 155 142 - l.Info("checking domain") 156 + tx, err := k.Db.Begin() 157 + if err != nil { 158 + l.Error("failed to start transaction", "err", err) 159 + fail() 160 + return 161 + } 162 + defer func() { 163 + tx.Rollback() 164 + k.Enforcer.E.LoadPolicy() 165 + }() 143 166 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 167 + err = db.AddKnot(tx, domain, user.Did) 145 168 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 169 + l.Error("failed to insert", "err", err) 147 170 fail() 148 171 return 149 172 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 173 + 174 + err = k.Enforcer.AddKnot(domain) 175 + if err != nil { 176 + l.Error("failed to create knot", "err", err) 177 + fail() 153 178 return 154 179 } 155 180 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 181 + // create record on pds 182 + client, err := k.OAuth.AuthorizedClient(r) 157 183 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 184 + l.Error("failed to authorize client", "err", err) 159 185 fail() 160 186 return 161 187 } 162 188 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 189 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 190 + var exCid *string 191 + if ex != nil { 192 + exCid = ex.Cid 193 + } 194 + 195 + // re-announce by registering under same rkey 196 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + Collection: tangled.KnotNSID, 198 + Repo: user.Did, 199 + Rkey: domain, 200 + Record: &lexutil.LexiconTypeDecoder{ 201 + Val: &tangled.Knot{ 202 + CreatedAt: time.Now().Format(time.RFC3339), 203 + }, 204 + }, 205 + SwapRecord: exCid, 206 + }) 207 + 164 208 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 209 + l.Error("failed to put record", "err", err) 166 210 fail() 167 211 return 168 212 } 169 213 170 - resp, err := client.Init(user.Did) 214 + err = tx.Commit() 171 215 if err != nil { 172 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 - l.Error("failed to make init request", "err", err) 216 + l.Error("failed to commit transaction", "err", err) 217 + fail() 174 218 return 175 219 } 176 220 177 - if resp.StatusCode == http.StatusConflict { 178 - k.Pages.Notice(w, noticeId, "This knot is already registered") 179 - l.Error("knot already registered", "statuscode", resp.StatusCode) 221 + err = k.Enforcer.E.SavePolicy() 222 + if err != nil { 223 + l.Error("failed to update ACL", "err", err) 224 + k.Pages.HxRefresh(w) 180 225 return 181 226 } 182 227 183 - if resp.StatusCode != http.StatusNoContent { 184 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 - l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 228 + // begin verification 229 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 230 + if err != nil { 231 + l.Error("verification failed", "err", err) 232 + k.Pages.HxRefresh(w) 186 233 return 187 234 } 188 235 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 236 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 192 237 if err != nil { 238 + l.Error("failed to mark verified", "err", err) 239 + k.Pages.HxRefresh(w) 193 240 return 194 241 } 195 242 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 243 + // add this knot to knotstream 244 + go k.Knotstream.AddSource( 245 + r.Context(), 246 + eventconsumer.NewKnotSource(domain), 247 + ) 248 + 249 + // ok 250 + k.Pages.HxRefresh(w) 251 + } 252 + 253 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 254 + user := k.OAuth.GetUser(r) 255 + l := k.Logger.With("handler", "delete") 256 + 257 + noticeId := "operation-error" 258 + defaultErr := "Failed to delete knot. Try again later." 259 + fail := func() { 260 + k.Pages.Notice(w, noticeId, defaultErr) 261 + } 198 262 199 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 - l.Error("signature mismatch", "bytes", signatureBytes) 263 + domain := chi.URLParam(r, "domain") 264 + if domain == "" { 265 + l.Error("empty domain") 266 + fail() 202 267 return 203 268 } 204 269 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 270 + // get record from db first 271 + registrations, err := db.GetRegistrations( 272 + k.Db, 273 + db.FilterEq("did", user.Did), 274 + db.FilterEq("domain", domain), 275 + ) 206 276 if err != nil { 207 - l.Error("failed to start tx", "err", err) 277 + l.Error("failed to get registration", "err", err) 278 + fail() 279 + return 280 + } 281 + if len(registrations) != 1 { 282 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 283 + fail() 284 + return 285 + } 286 + registration := registrations[0] 287 + 288 + tx, err := k.Db.Begin() 289 + if err != nil { 290 + l.Error("failed to start txn", "err", err) 208 291 fail() 209 292 return 210 293 } 211 294 defer func() { 212 295 tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 296 + k.Enforcer.E.LoadPolicy() 217 297 }() 218 298 219 - // mark as registered 220 - err = db.Register(tx, domain) 299 + err = db.DeleteKnot( 300 + tx, 301 + db.FilterEq("did", user.Did), 302 + db.FilterEq("domain", domain), 303 + ) 221 304 if err != nil { 222 - l.Error("failed to register domain", "err", err) 305 + l.Error("failed to delete registration", "err", err) 223 306 fail() 224 307 return 225 308 } 226 309 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 229 - if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 231 - fail() 232 - return 310 + // delete from enforcer if it was registered 311 + if registration.Registered != nil { 312 + err = k.Enforcer.RemoveKnot(domain) 313 + if err != nil { 314 + l.Error("failed to update ACL", "err", err) 315 + fail() 316 + return 317 + } 233 318 } 234 319 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 320 + client, err := k.OAuth.AuthorizedClient(r) 237 321 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 322 + l.Error("failed to authorize client", "err", err) 239 323 fail() 240 324 return 241 325 } 242 326 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 327 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 328 + Collection: tangled.KnotNSID, 329 + Repo: user.Did, 330 + Rkey: domain, 331 + }) 245 332 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 - fail() 248 - return 333 + // non-fatal 334 + l.Error("failed to delete record", "err", err) 249 335 } 250 336 251 337 err = tx.Commit() 252 338 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 339 + l.Error("failed to delete knot", "err", err) 254 340 fail() 255 341 return 256 342 } 257 343 258 344 err = k.Enforcer.E.SavePolicy() 259 345 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 346 + l.Error("failed to update ACL", "err", err) 347 + k.Pages.HxRefresh(w) 262 348 return 263 349 } 264 350 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 351 + shouldRedirect := r.Header.Get("shouldRedirect") 352 + if shouldRedirect == "true" { 353 + k.Pages.HxRedirect(w, "/knots") 354 + return 355 + } 270 356 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 357 + w.Write([]byte{}) 274 358 } 275 359 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 360 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 361 + user := k.OAuth.GetUser(r) 362 + l := k.Logger.With("handler", "retry") 363 + 364 + noticeId := "operation-error" 365 + defaultErr := "Failed to verify knot. Try again later." 278 366 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 367 + k.Pages.Notice(w, noticeId, defaultErr) 280 368 } 281 369 282 370 domain := chi.URLParam(r, "domain") 283 371 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 372 + l.Error("empty domain") 373 + fail() 285 374 return 286 375 } 287 376 l = l.With("domain", domain) 377 + l = l.With("user", user.Did) 288 378 289 - user := k.OAuth.GetUser(r) 290 - l = l.With("did", user.Did) 291 - 292 - // dashboard is only available to owners 293 - ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 379 + // get record from db first 380 + registrations, err := db.GetRegistrations( 381 + k.Db, 382 + db.FilterEq("did", user.Did), 383 + db.FilterEq("domain", domain), 384 + ) 294 385 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 386 + l.Error("failed to get registration", "err", err) 296 387 fail() 388 + return 297 389 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 390 + if len(registrations) != 1 { 391 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 392 + fail() 300 393 return 301 394 } 395 + registration := registrations[0] 302 396 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 397 + // begin verification 398 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 304 399 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 400 + l.Error("verification failed", "err", err) 401 + 402 + if errors.Is(err, serververify.FetchError) { 403 + k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 + return 405 + } 406 + 407 + if e, ok := err.(*serververify.OwnerMismatch); ok { 408 + k.Pages.Notice(w, noticeId, e.Error()) 409 + return 410 + } 411 + 306 412 fail() 307 413 return 308 414 } 309 415 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 416 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 417 + if err != nil { 418 + l.Error("failed to mark verified", "err", err) 419 + k.Pages.Notice(w, noticeId, err.Error()) 420 + return 421 + } 422 + 423 + // if this knot was previously read-only, then emit a record too 424 + // 425 + // this is part of migrating from the old knot system to the new one 426 + if registration.ReadOnly { 427 + // re-announce by registering under same rkey 428 + client, err := k.OAuth.AuthorizedClient(r) 313 429 if err != nil { 314 - l.Error("failed to get members list", "err", err) 430 + l.Error("failed to authorize client", "err", err) 315 431 fail() 316 432 return 317 433 } 434 + 435 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 436 + var exCid *string 437 + if ex != nil { 438 + exCid = ex.Cid 439 + } 440 + 441 + // ignore the error here 442 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 443 + Collection: tangled.KnotNSID, 444 + Repo: user.Did, 445 + Rkey: domain, 446 + Record: &lexutil.LexiconTypeDecoder{ 447 + Val: &tangled.Knot{ 448 + CreatedAt: time.Now().Format(time.RFC3339), 449 + }, 450 + }, 451 + SwapRecord: exCid, 452 + }) 453 + if err != nil { 454 + l.Error("non-fatal: failed to reannouce knot", "err", err) 455 + } 318 456 } 319 457 320 - repos, err := db.GetRepos( 458 + // add this knot to knotstream 459 + go k.Knotstream.AddSource( 460 + r.Context(), 461 + eventconsumer.NewKnotSource(domain), 462 + ) 463 + 464 + shouldRefresh := r.Header.Get("shouldRefresh") 465 + if shouldRefresh == "true" { 466 + k.Pages.HxRefresh(w) 467 + return 468 + } 469 + 470 + // Get updated registration to show 471 + registrations, err = db.GetRegistrations( 321 472 k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 473 + db.FilterEq("did", user.Did), 474 + db.FilterEq("domain", domain), 325 475 ) 326 476 if err != nil { 327 - l.Error("failed to get repos list", "err", err) 477 + l.Error("failed to get registration", "err", err) 328 478 fail() 329 479 return 330 480 } 331 - // convert to map 332 - repoByMember := make(map[string][]db.Repo) 333 - for _, r := range repos { 334 - repoByMember[r.Did] = append(repoByMember[r.Did], r) 481 + if len(registrations) != 1 { 482 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 483 + fail() 484 + return 335 485 } 486 + updatedRegistration := registrations[0] 336 487 337 - var didsToResolve []string 338 - for _, m := range members { 339 - didsToResolve = append(didsToResolve, m) 340 - } 341 - didsToResolve = append(didsToResolve, reg.ByDid) 342 - resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve) 343 - didHandleMap := make(map[string]string) 344 - for _, identity := range resolvedIds { 345 - if !identity.Handle.IsInvalidHandle() { 346 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 347 - } else { 348 - didHandleMap[identity.DID.String()] = identity.DID.String() 349 - } 350 - } 488 + log.Println(updatedRegistration) 351 489 352 - k.Pages.Knot(w, pages.KnotParams{ 353 - LoggedInUser: user, 354 - DidHandleMap: didHandleMap, 355 - Registration: reg, 356 - Members: members, 357 - Repos: repoByMember, 358 - IsOwner: true, 490 + w.Header().Set("HX-Reswap", "outerHTML") 491 + k.Pages.KnotListing(w, pages.KnotListingParams{ 492 + Registration: &updatedRegistration, 359 493 }) 360 494 } 361 495 362 - // list members of domain, requires auth and requires owner status 363 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 - l := k.Logger.With("handler", "members") 496 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 497 + user := k.OAuth.GetUser(r) 498 + l := k.Logger.With("handler", "addMember") 365 499 366 500 domain := chi.URLParam(r, "domain") 367 501 if domain == "" { 368 - http.Error(w, "malformed url", http.StatusBadRequest) 502 + l.Error("empty domain") 503 + http.Error(w, "Not found", http.StatusNotFound) 369 504 return 370 505 } 371 506 l = l.With("domain", domain) 507 + l = l.With("user", user.Did) 372 508 373 - // list all members for this domain 374 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 509 + registrations, err := db.GetRegistrations( 510 + k.Db, 511 + db.FilterEq("did", user.Did), 512 + db.FilterEq("domain", domain), 513 + db.FilterIsNot("registered", "null"), 514 + ) 375 515 if err != nil { 376 - w.Write([]byte("failed to fetch member list")) 377 - return 378 - } 379 - 380 - w.Write([]byte(strings.Join(memberDids, "\n"))) 381 - } 382 - 383 - // add member to domain, requires auth and requires invite access 384 - func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 385 - l := k.Logger.With("handler", "members") 386 - 387 - domain := chi.URLParam(r, "domain") 388 - if domain == "" { 389 - http.Error(w, "malformed url", http.StatusBadRequest) 516 + l.Error("failed to get registration", "err", err) 390 517 return 391 518 } 392 - l = l.With("domain", domain) 393 - 394 - reg, err := db.RegistrationByDomain(k.Db, domain) 395 - if err != nil { 396 - l.Error("failed to get registration by domain", "err", err) 397 - http.Error(w, "malformed url", http.StatusBadRequest) 519 + if len(registrations) != 1 { 520 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 398 521 return 399 522 } 523 + registration := registrations[0] 400 524 401 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 402 - l = l.With("notice-id", noticeId) 525 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 403 526 defaultErr := "Failed to add member. Try again later." 404 527 fail := func() { 405 528 k.Pages.Notice(w, noticeId, defaultErr) 406 529 } 407 530 408 - subjectIdentifier := r.FormValue("subject") 409 - if subjectIdentifier == "" { 410 - http.Error(w, "malformed form", http.StatusBadRequest) 531 + member := r.FormValue("member") 532 + if member == "" { 533 + l.Error("empty member") 534 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 411 535 return 412 536 } 413 - l = l.With("subjectIdentifier", subjectIdentifier) 537 + l = l.With("member", member) 414 538 415 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 539 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 416 540 if err != nil { 417 - l.Error("failed to resolve identity", "err", err) 541 + l.Error("failed to resolve member identity to handle", "err", err) 418 542 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 419 543 return 420 544 } 421 - l = l.With("subjectDid", subjectIdentity.DID) 422 - 423 - l.Info("adding member to knot") 545 + if memberId.Handle.IsInvalidHandle() { 546 + l.Error("failed to resolve member identity to handle") 547 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 548 + return 549 + } 424 550 425 - // announce this relation into the firehose, store into owners' pds 551 + // write to pds 426 552 client, err := k.OAuth.AuthorizedClient(r) 427 553 if err != nil { 428 - l.Error("failed to create client", "err", err) 554 + l.Error("failed to authorize client", "err", err) 429 555 fail() 430 556 return 431 557 } 432 558 433 - currentUser := k.OAuth.GetUser(r) 434 - createdAt := time.Now().Format(time.RFC3339) 435 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 + rkey := tid.TID() 560 + 561 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 562 Collection: tangled.KnotMemberNSID, 437 - Repo: currentUser.Did, 438 - Rkey: tid.TID(), 563 + Repo: user.Did, 564 + Rkey: rkey, 439 565 Record: &lexutil.LexiconTypeDecoder{ 440 566 Val: &tangled.KnotMember{ 441 - Subject: subjectIdentity.DID.String(), 567 + CreatedAt: time.Now().Format(time.RFC3339), 442 568 Domain: domain, 443 - CreatedAt: createdAt, 444 - }}, 569 + Subject: memberId.DID.String(), 570 + }, 571 + }, 445 572 }) 446 - // invalid record 447 573 if err != nil { 448 - l.Error("failed to write to PDS", "err", err) 449 - fail() 574 + l.Error("failed to add record to PDS", "err", err) 575 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 450 576 return 451 577 } 452 - l = l.With("at-uri", resp.Uri) 453 - l.Info("wrote record to PDS") 454 578 455 - secret, err := db.GetRegistrationKey(k.Db, domain) 579 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 456 580 if err != nil { 457 - l.Error("failed to get registration key", "err", err) 581 + l.Error("failed to add member to ACLs", "err", err) 458 582 fail() 459 583 return 460 584 } 461 585 462 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 586 + err = k.Enforcer.E.SavePolicy() 463 587 if err != nil { 464 - l.Error("failed to create client", "err", err) 588 + l.Error("failed to save ACL policy", "err", err) 465 589 fail() 466 590 return 467 591 } 468 592 469 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 593 + // success 594 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 595 + } 596 + 597 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 598 + user := k.OAuth.GetUser(r) 599 + l := k.Logger.With("handler", "removeMember") 600 + 601 + noticeId := "operation-error" 602 + defaultErr := "Failed to remove member. Try again later." 603 + fail := func() { 604 + k.Pages.Notice(w, noticeId, defaultErr) 605 + } 606 + 607 + domain := chi.URLParam(r, "domain") 608 + if domain == "" { 609 + l.Error("empty domain") 610 + fail() 611 + return 612 + } 613 + l = l.With("domain", domain) 614 + l = l.With("user", user.Did) 615 + 616 + registrations, err := db.GetRegistrations( 617 + k.Db, 618 + db.FilterEq("did", user.Did), 619 + db.FilterEq("domain", domain), 620 + db.FilterIsNot("registered", "null"), 621 + ) 470 622 if err != nil { 471 - l.Error("failed to reach knotserver", "err", err) 472 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 623 + l.Error("failed to get registration", "err", err) 624 + return 625 + } 626 + if len(registrations) != 1 { 627 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 473 628 return 474 629 } 475 630 476 - if ksResp.StatusCode != http.StatusNoContent { 477 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 478 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 631 + member := r.FormValue("member") 632 + if member == "" { 633 + l.Error("empty member") 634 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 479 635 return 480 636 } 637 + l = l.With("member", member) 481 638 482 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 639 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 483 640 if err != nil { 484 - l.Error("failed to add member to enforcer", "err", err) 641 + l.Error("failed to resolve member identity to handle", "err", err) 642 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 + return 644 + } 645 + if memberId.Handle.IsInvalidHandle() { 646 + l.Error("failed to resolve member identity to handle") 647 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 648 + return 649 + } 650 + 651 + // remove from enforcer 652 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 653 + if err != nil { 654 + l.Error("failed to update ACLs", "err", err) 485 655 fail() 486 656 return 487 657 } 488 658 489 - // success 490 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 659 + client, err := k.OAuth.AuthorizedClient(r) 660 + if err != nil { 661 + l.Error("failed to authorize client", "err", err) 662 + fail() 663 + return 664 + } 665 + 666 + // TODO: We need to track the rkey for knot members to delete the record 667 + // For now, just remove from ACLs 668 + _ = client 669 + 670 + // commit everything 671 + err = k.Enforcer.E.SavePolicy() 672 + if err != nil { 673 + l.Error("failed to save ACLs", "err", err) 674 + fail() 675 + return 676 + } 677 + 678 + // ok 679 + k.Pages.HxRefresh(w) 491 680 } 492 681 493 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 682 + func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 + user := k.OAuth.GetUser(r) 684 + l := k.Logger.With("handler", "removeMember") 685 + l = l.With("did", user.Did) 686 + l = l.With("handle", user.Handle) 687 + 688 + registrations, err := db.GetRegistrations( 689 + k.Db, 690 + db.FilterEq("did", user.Did), 691 + db.FilterEq("read_only", 1), 692 + ) 693 + if err != nil { 694 + l.Error("non-fatal: failed to get registrations") 695 + return 696 + } 697 + 698 + if registrations == nil { 699 + return 700 + } 701 + 702 + k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 + Registrations: registrations, 704 + }) 494 705 }
+17 -14
appview/middleware/middleware.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "net/url" 8 9 "slices" 9 10 "strconv" 10 11 "strings" 11 - "time" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" ··· 46 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + returnURL := "/" 50 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 51 + returnURL = u.RequestURI() 52 + } 53 + 54 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 55 + 49 56 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 50 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 57 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 51 58 } 52 59 if r.Header.Get("HX-Request") == "true" { 53 60 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 54 - w.Header().Set("HX-Redirect", "/login") 61 + w.Header().Set("HX-Redirect", loginURL) 55 62 w.WriteHeader(http.StatusOK) 56 63 } 57 64 } ··· 183 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 184 191 if err != nil { 185 192 // invalid did or handle 186 - log.Println("failed to resolve did/handle:", err) 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 187 194 mw.pages.Error404(w) 188 195 return 189 196 } ··· 210 217 if err != nil { 211 218 // invalid did or handle 212 219 log.Println("failed to resolve repo") 213 - mw.pages.Error404(w) 220 + mw.pages.ErrorKnot404(w) 214 221 return 215 222 } 216 223 217 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 218 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 219 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 220 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 221 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 222 225 next.ServeHTTP(w, req.WithContext(ctx)) 223 226 }) 224 227 } ··· 231 234 f, err := mw.repoResolver.Resolve(r) 232 235 if err != nil { 233 236 log.Println("failed to fully resolve repo", err) 234 - http.Error(w, "invalid repo url", http.StatusNotFound) 237 + mw.pages.ErrorKnot404(w) 235 238 return 236 239 } 237 240 ··· 243 246 return 244 247 } 245 248 246 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 247 250 if err != nil { 248 251 log.Println("failed to get pull and comments", err) 249 252 return ··· 280 283 f, err := mw.repoResolver.Resolve(r) 281 284 if err != nil { 282 285 log.Println("failed to fully resolve repo", err) 283 - http.Error(w, "invalid repo url", http.StatusNotFound) 286 + mw.pages.ErrorKnot404(w) 284 287 return 285 288 } 286 289 287 - fullName := f.OwnerHandle() + "/" + f.RepoName 290 + fullName := f.OwnerHandle() + "/" + f.Name 288 291 289 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 290 293 if r.URL.Query().Get("go-get") == "1" {
+124 -86
appview/oauth/handler/handler.go
··· 8 8 "log" 9 9 "net/http" 10 10 "net/url" 11 + "slices" 11 12 "strings" 12 13 "time" 13 14 ··· 25 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 26 27 "tangled.sh/tangled.sh/core/appview/pages" 27 28 "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/knotclient" 29 29 "tangled.sh/tangled.sh/core/rbac" 30 30 "tangled.sh/tangled.sh/core/tid" 31 31 ) ··· 109 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 110 110 switch r.Method { 111 111 case http.MethodGet: 112 - o.pages.Login(w, pages.LoginParams{}) 112 + returnURL := r.URL.Query().Get("return_url") 113 + o.pages.Login(w, pages.LoginParams{ 114 + ReturnUrl: returnURL, 115 + }) 113 116 case http.MethodPost: 114 117 handle := r.FormValue("handle") 115 118 ··· 194 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 195 198 DpopPrivateJwk: string(dpopKeyJson), 196 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 197 201 }) 198 202 if err != nil { 199 203 log.Println("failed to save oauth request:", err) ··· 245 249 iss := r.FormValue("iss") 246 250 if iss == "" { 247 251 log.Println("missing iss for state: ", state) 252 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 253 + return 254 + } 255 + 256 + if iss != oauthRequest.AuthserverIss { 257 + log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 248 258 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 249 259 return 250 260 } ··· 311 321 } 312 322 } 313 323 314 - http.Redirect(w, r, "/", http.StatusFound) 324 + returnUrl := oauthRequest.ReturnUrl 325 + if returnUrl == "" { 326 + returnUrl = "/" 327 + } 328 + 329 + http.Redirect(w, r, returnUrl, http.StatusFound) 315 330 } 316 331 317 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 338 353 return pubKey, nil 339 354 } 340 355 356 + var ( 357 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 + icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 359 + 360 + defaultSpindle = "spindle.tangled.sh" 361 + defaultKnot = "knot1.tangled.sh" 362 + ) 363 + 341 364 func (o *OAuthHandler) addToDefaultSpindle(did string) { 342 365 // use the tangled.sh app password to get an accessJwt 343 366 // and create an sh.tangled.spindle.member record with that 344 - 345 - defaultSpindle := "spindle.tangled.sh" 346 - appPassword := o.config.Core.AppPassword 347 - 348 367 spindleMembers, err := db.GetSpindleMembers( 349 368 o.db, 350 369 db.FilterEq("instance", "spindle.tangled.sh"), ··· 360 379 return 361 380 } 362 381 363 - // TODO: hardcoded tangled handle and did for now 364 - tangledHandle := "tangled.sh" 365 - tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 382 + log.Printf("adding %s to default spindle", did) 383 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 384 + if err != nil { 385 + log.Printf("failed to create session: %s", err) 386 + return 387 + } 388 + 389 + record := tangled.SpindleMember{ 390 + LexiconTypeID: "sh.tangled.spindle.member", 391 + Subject: did, 392 + Instance: defaultSpindle, 393 + CreatedAt: time.Now().Format(time.RFC3339), 394 + } 395 + 396 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 397 + log.Printf("failed to add member to default spindle: %s", err) 398 + return 399 + } 400 + 401 + log.Printf("successfully added %s to default spindle", did) 402 + } 403 + 404 + func (o *OAuthHandler) addToDefaultKnot(did string) { 405 + // use the tangled.sh app password to get an accessJwt 406 + // and create an sh.tangled.spindle.member record with that 366 407 367 - if appPassword == "" { 368 - log.Println("no app password configured, skipping spindle member addition") 408 + allKnots, err := o.enforcer.GetKnotsForUser(did) 409 + if err != nil { 410 + log.Printf("failed to get knot members for did %s: %v", did, err) 369 411 return 370 412 } 371 413 372 - log.Printf("adding %s to default spindle", did) 414 + if slices.Contains(allKnots, defaultKnot) { 415 + log.Printf("did %s is already a member of the default knot", did) 416 + return 417 + } 373 418 374 - resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 419 + log.Printf("adding %s to default knot", did) 420 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 375 421 if err != nil { 376 - log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 422 + log.Printf("failed to create session: %s", err) 377 423 return 378 424 } 379 425 426 + record := tangled.KnotMember{ 427 + LexiconTypeID: "sh.tangled.knot.member", 428 + Subject: did, 429 + Domain: defaultKnot, 430 + CreatedAt: time.Now().Format(time.RFC3339), 431 + } 432 + 433 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 434 + log.Printf("failed to add member to default knot: %s", err) 435 + return 436 + } 437 + 438 + if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil { 439 + log.Printf("failed to set up enforcer rules: %s", err) 440 + return 441 + } 442 + 443 + log.Printf("successfully added %s to default Knot", did) 444 + } 445 + 446 + // create a session using apppasswords 447 + type session struct { 448 + AccessJwt string `json:"accessJwt"` 449 + PdsEndpoint string 450 + Did string 451 + } 452 + 453 + func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 454 + if appPassword == "" { 455 + return nil, fmt.Errorf("no app password configured, skipping member addition") 456 + } 457 + 458 + resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 459 + if err != nil { 460 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 461 + } 462 + 380 463 pdsEndpoint := resolved.PDSEndpoint() 381 464 if pdsEndpoint == "" { 382 - log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 383 - return 465 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 384 466 } 385 467 386 468 sessionPayload := map[string]string{ 387 - "identifier": tangledHandle, 469 + "identifier": did, 388 470 "password": appPassword, 389 471 } 390 472 sessionBytes, err := json.Marshal(sessionPayload) 391 473 if err != nil { 392 - log.Printf("failed to marshal session payload: %v", err) 393 - return 474 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 394 475 } 395 476 396 477 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 397 478 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 398 479 if err != nil { 399 - log.Printf("failed to create session request: %v", err) 400 - return 480 + return nil, fmt.Errorf("failed to create session request: %v", err) 401 481 } 402 482 sessionReq.Header.Set("Content-Type", "application/json") 403 483 404 484 client := &http.Client{Timeout: 30 * time.Second} 405 485 sessionResp, err := client.Do(sessionReq) 406 486 if err != nil { 407 - log.Printf("failed to create session: %v", err) 408 - return 487 + return nil, fmt.Errorf("failed to create session: %v", err) 409 488 } 410 489 defer sessionResp.Body.Close() 411 490 412 491 if sessionResp.StatusCode != http.StatusOK { 413 - log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 414 - return 492 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 415 493 } 416 494 417 - var session struct { 418 - AccessJwt string `json:"accessJwt"` 419 - } 495 + var session session 420 496 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 421 - log.Printf("failed to decode session response: %v", err) 422 - return 497 + return nil, fmt.Errorf("failed to decode session response: %v", err) 423 498 } 424 499 425 - record := tangled.SpindleMember{ 426 - LexiconTypeID: "sh.tangled.spindle.member", 427 - Subject: did, 428 - Instance: defaultSpindle, 429 - CreatedAt: time.Now().Format(time.RFC3339), 430 - } 500 + session.PdsEndpoint = pdsEndpoint 501 + session.Did = did 431 502 503 + return &session, nil 504 + } 505 + 506 + func (s *session) putRecord(record any, collection string) error { 432 507 recordBytes, err := json.Marshal(record) 433 508 if err != nil { 434 - log.Printf("failed to marshal spindle member record: %v", err) 435 - return 509 + return fmt.Errorf("failed to marshal knot member record: %w", err) 436 510 } 437 511 438 - payload := map[string]interface{}{ 439 - "repo": tangledDid, 440 - "collection": tangled.SpindleMemberNSID, 512 + payload := map[string]any{ 513 + "repo": s.Did, 514 + "collection": collection, 441 515 "rkey": tid.TID(), 442 516 "record": json.RawMessage(recordBytes), 443 517 } 444 518 445 519 payloadBytes, err := json.Marshal(payload) 446 520 if err != nil { 447 - log.Printf("failed to marshal request payload: %v", err) 448 - return 521 + return fmt.Errorf("failed to marshal request payload: %w", err) 449 522 } 450 523 451 - url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 524 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 452 525 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 453 526 if err != nil { 454 - log.Printf("failed to create HTTP request: %v", err) 455 - return 527 + return fmt.Errorf("failed to create HTTP request: %w", err) 456 528 } 457 529 458 530 req.Header.Set("Content-Type", "application/json") 459 - req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 531 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 460 532 533 + client := &http.Client{Timeout: 30 * time.Second} 461 534 resp, err := client.Do(req) 462 535 if err != nil { 463 - log.Printf("failed to add user to default spindle: %v", err) 464 - return 536 + return fmt.Errorf("failed to add user to default service: %w", err) 465 537 } 466 538 defer resp.Body.Close() 467 539 468 540 if resp.StatusCode != http.StatusOK { 469 - log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 470 - return 471 - } 472 - 473 - log.Printf("successfully added %s to default spindle", did) 474 - } 475 - 476 - func (o *OAuthHandler) addToDefaultKnot(did string) { 477 - defaultKnot := "knot1.tangled.sh" 478 - 479 - log.Printf("adding %s to default knot", did) 480 - err := o.enforcer.AddKnotMember(defaultKnot, did) 481 - if err != nil { 482 - log.Println("failed to add user to knot1.tangled.sh: ", err) 483 - return 484 - } 485 - err = o.enforcer.E.SavePolicy() 486 - if err != nil { 487 - log.Println("failed to add user to knot1.tangled.sh: ", err) 488 - return 489 - } 490 - 491 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 492 - if err != nil { 493 - log.Println("failed to get registration key for knot1.tangled.sh") 494 - return 495 - } 496 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 497 - resp, err := signedClient.AddMember(did) 498 - if err != nil { 499 - log.Println("failed to add user to knot1.tangled.sh: ", err) 500 - return 541 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 501 542 } 502 543 503 - if resp.StatusCode != http.StatusNoContent { 504 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 505 - return 506 - } 544 + return nil 507 545 }
+16 -3
appview/oauth/oauth.go
··· 103 103 if err != nil { 104 104 return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 105 } 106 - if expiry.Sub(time.Now()) <= 5*time.Minute { 106 + if time.Until(expiry) <= 5*time.Minute { 107 107 privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 108 if err != nil { 109 109 return nil, false, err ··· 224 224 s.service = service 225 225 } 226 226 } 227 + 228 + // Specify the Duration in seconds for the expiry of this token 229 + // 230 + // The time of expiry is calculated as time.Now().Unix() + exp 227 231 func WithExp(exp int64) ServiceClientOpt { 228 232 return func(s *ServiceClientOpts) { 229 - s.exp = exp 233 + s.exp = time.Now().Unix() + exp 230 234 } 231 235 } 232 236 ··· 266 270 return nil, err 267 271 } 268 272 273 + // force expiry to atleast 60 seconds in the future 274 + sixty := time.Now().Unix() + 60 275 + if opts.exp < sixty { 276 + opts.exp = sixty 277 + } 278 + 269 279 resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 270 280 if err != nil { 271 281 return nil, err ··· 276 286 AccessJwt: resp.Token, 277 287 }, 278 288 Host: opts.Host(), 289 + Client: &http.Client{ 290 + Timeout: time.Second * 5, 291 + }, 279 292 }, nil 280 293 } 281 294 ··· 305 318 redirectURIs := makeRedirectURIs(clientURI) 306 319 307 320 if o.config.Core.Dev { 308 - clientURI = fmt.Sprintf("http://127.0.0.1:3000") 321 + clientURI = "http://127.0.0.1:3000" 309 322 redirectURIs = makeRedirectURIs(clientURI) 310 323 311 324 query := url.Values{}
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+42 -6
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "context" 4 5 "crypto/hmac" 5 6 "crypto/sha256" 6 7 "encoding/hex" ··· 18 19 19 20 "github.com/dustin/go-humanize" 20 21 "github.com/go-enry/go-enry/v2" 21 - "github.com/microcosm-cc/bluemonday" 22 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 + "tangled.sh/tangled.sh/core/crypto" 24 25 ) 25 26 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 28 29 "split": func(s string) []string { 29 30 return strings.Split(s, "\n") 30 31 }, 32 + "resolve": func(s string) string { 33 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 34 + 35 + if err != nil { 36 + return s 37 + } 38 + 39 + if identity.Handle.IsInvalidHandle() { 40 + return "handle.invalid" 41 + } 42 + 43 + return "@" + identity.Handle.String() 44 + }, 31 45 "truncateAt30": func(s string) string { 32 46 if len(s) <= 30 { 33 47 return s ··· 74 88 "negf64": func(a float64) float64 { 75 89 return -a 76 90 }, 77 - "cond": func(cond interface{}, a, b string) string { 91 + "cond": func(cond any, a, b string) string { 78 92 if cond == nil { 79 93 return b 80 94 } ··· 167 181 return html.UnescapeString(s) 168 182 }, 169 183 "nl2br": func(text string) template.HTML { 170 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 184 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 171 185 }, 172 186 "unwrapText": func(text string) string { 173 187 paragraphs := strings.Split(text, "\n\n") ··· 193 207 } 194 208 return v.Slice(0, min(n, v.Len())).Interface() 195 209 }, 196 - 197 210 "markdown": func(text string) template.HTML { 198 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 199 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 211 + p.rctx.RendererType = markup.RendererTypeDefault 212 + htmlString := p.rctx.RenderMarkdown(text) 213 + sanitized := p.rctx.SanitizeDefault(htmlString) 214 + return template.HTML(sanitized) 215 + }, 216 + "description": func(text string) template.HTML { 217 + p.rctx.RendererType = markup.RendererTypeDefault 218 + htmlString := p.rctx.RenderMarkdown(text) 219 + sanitized := p.rctx.SanitizeDescription(htmlString) 220 + return template.HTML(sanitized) 200 221 }, 201 222 "isNil": func(t any) bool { 202 223 // returns false for other "zero" values ··· 236 257 }, 237 258 "cssContentHash": CssContentHash, 238 259 "fileTree": filetree.FileTree, 260 + "pathEscape": func(s string) string { 261 + return url.PathEscape(s) 262 + }, 239 263 "pathUnescape": func(s string) string { 240 264 u, _ := url.PathUnescape(s) 241 265 return u ··· 253 277 }, 254 278 "layoutCenter": func() string { 255 279 return "col-span-1 md:col-span-8 lg:col-span-6" 280 + }, 281 + 282 + "normalizeForHtmlId": func(s string) string { 283 + // TODO: extend this to handle other cases? 284 + return strings.ReplaceAll(s, ":", "_") 285 + }, 286 + "sshFingerprint": func(pubKey string) string { 287 + fp, err := crypto.SSHFingerprint(pubKey) 288 + if err != nil { 289 + return "error" 290 + } 291 + return fp 256 292 }, 257 293 } 258 294 }
+63 -31
appview/pages/markup/markdown.go
··· 9 9 "path" 10 10 "strings" 11 11 12 - "github.com/microcosm-cc/bluemonday" 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 14 + treeblood "github.com/wyatt915/goldmark-treeblood" 13 15 "github.com/yuin/goldmark" 16 + highlighting "github.com/yuin/goldmark-highlighting/v2" 14 17 "github.com/yuin/goldmark/ast" 15 18 "github.com/yuin/goldmark/extension" 16 19 "github.com/yuin/goldmark/parser" ··· 40 43 repoinfo.RepoInfo 41 44 IsDev bool 42 45 RendererType RendererType 46 + Sanitizer Sanitizer 43 47 } 44 48 45 49 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 50 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 51 + goldmark.WithExtensions( 52 + extension.GFM, 53 + highlighting.NewHighlighting( 54 + highlighting.WithFormatOptions( 55 + chromahtml.Standalone(false), 56 + chromahtml.WithClasses(true), 57 + ), 58 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 59 + ), 60 + extension.NewFootnote( 61 + extension.WithFootnoteIDPrefix([]byte("footnote")), 62 + ), 63 + treeblood.MathML(), 64 + ), 48 65 goldmark.WithParserOptions( 49 66 parser.WithAutoHeadingID(), 50 67 ), ··· 145 162 } 146 163 } 147 164 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 150 - 151 - // video 152 - policy.AllowElements("video") 153 - policy.AllowAttrs("controls").OnElements("video") 154 - policy.AllowElements("source") 155 - policy.AllowAttrs("src", "type").OnElements("source") 156 - 157 - // centering content 158 - policy.AllowElements("center") 165 + func (rctx *RenderContext) SanitizeDefault(html string) string { 166 + return rctx.Sanitizer.SanitizeDefault(html) 167 + } 159 168 160 - policy.AllowAttrs("align", "style", "width", "height").Globally() 161 - policy.AllowStyles( 162 - "margin", 163 - "padding", 164 - "text-align", 165 - "font-weight", 166 - "text-decoration", 167 - "padding-left", 168 - "padding-right", 169 - "padding-top", 170 - "padding-bottom", 171 - "margin-left", 172 - "margin-right", 173 - "margin-top", 174 - "margin-bottom", 175 - ) 176 - return policy.Sanitize(html) 169 + func (rctx *RenderContext) SanitizeDescription(html string) string { 170 + return rctx.Sanitizer.SanitizeDescription(html) 177 171 } 178 172 179 173 type MarkdownTransformer struct { ··· 189 183 switch a.rctx.RendererType { 190 184 case RendererTypeRepoMarkdown: 191 185 switch n := n.(type) { 186 + case *ast.Heading: 187 + a.rctx.anchorHeadingTransformer(n) 192 188 case *ast.Link: 193 189 a.rctx.relativeLinkTransformer(n) 194 190 case *ast.Image: ··· 197 193 } 198 194 case RendererTypeDefault: 199 195 switch n := n.(type) { 196 + case *ast.Heading: 197 + a.rctx.anchorHeadingTransformer(n) 200 198 case *ast.Image: 201 199 a.rctx.imageFromKnotAstTransformer(n) 202 200 a.rctx.camoImageLinkAstTransformer(n) ··· 211 209 212 210 dst := string(link.Destination) 213 211 214 - if isAbsoluteUrl(dst) { 212 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 213 return 216 214 } 217 215 ··· 252 250 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 251 } 254 252 253 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 254 + idGeneric, exists := h.AttributeString("id") 255 + if !exists { 256 + return // no id, nothing to do 257 + } 258 + id, ok := idGeneric.([]byte) 259 + if !ok { 260 + return 261 + } 262 + 263 + // create anchor link 264 + anchor := ast.NewLink() 265 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 266 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 267 + 268 + // create icon text 269 + iconText := ast.NewString([]byte("#")) 270 + anchor.AppendChild(anchor, iconText) 271 + 272 + // set class on heading 273 + h.SetAttribute([]byte("class"), []byte("heading")) 274 + 275 + // append anchor to heading 276 + h.AppendChild(h, anchor) 277 + } 278 + 255 279 // actualPath decides when to join the file path with the 256 280 // current repository directory (essentially only when the link 257 281 // destination is relative. if it's absolute then we assume the ··· 271 295 } 272 296 return parsed.IsAbs() 273 297 } 298 + 299 + func isFragment(link string) bool { 300 + return strings.HasPrefix(link, "#") 301 + } 302 + 303 + func isMail(link string) bool { 304 + return strings.HasPrefix(link, "mailto:") 305 + }
+134
appview/pages/markup/sanitizer.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + // math 101 + mathAttrs := []string{ 102 + "accent", "columnalign", "columnlines", "columnspan", "dir", "display", 103 + "displaystyle", "encoding", "fence", "form", "largeop", "linebreak", 104 + "linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize", 105 + "movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan", 106 + "scriptlevel", "stretchy", "symmetric", "title", "voffset", "width", 107 + } 108 + mathElements := []string{ 109 + "annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts", 110 + "mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace", 111 + "msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext", 112 + "mtr", "munder", "munderover", "semantics", 113 + } 114 + policy.AllowNoAttrs().OnElements(mathElements...) 115 + policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 + 117 + return policy 118 + } 119 + 120 + func descriptionPolicy() *bluemonday.Policy { 121 + policy := bluemonday.NewPolicy() 122 + policy.AllowStandardURLs() 123 + 124 + // allow italics and bold. 125 + policy.AllowElements("i", "b", "em", "strong") 126 + 127 + // allow code. 128 + policy.AllowElements("code") 129 + 130 + // allow links 131 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 132 + 133 + return policy 134 + }
+317 -230
appview/pages/pages.go
··· 9 9 "html/template" 10 10 "io" 11 11 "io/fs" 12 - "log" 12 + "log/slog" 13 13 "net/http" 14 14 "os" 15 15 "path/filepath" ··· 24 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 + "tangled.sh/tangled.sh/core/idresolver" 27 28 "tangled.sh/tangled.sh/core/patchutil" 28 29 "tangled.sh/tangled.sh/core/types" 29 30 ··· 41 42 var Files embed.FS 42 43 43 44 type Pages struct { 44 - mu sync.RWMutex 45 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 46 47 47 48 avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 48 50 dev bool 49 - embedFS embed.FS 51 + embedFS fs.FS 50 52 templateDir string // Path to templates on disk for dev mode 51 53 rctx *markup.RenderContext 54 + logger *slog.Logger 52 55 } 53 56 54 - func NewPages(config *config.Config) *Pages { 57 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 55 58 // initialized with safe defaults, can be overriden per use 56 59 rctx := &markup.RenderContext{ 57 60 IsDev: config.Core.Dev, 58 61 CamoUrl: config.Camo.Host, 59 62 CamoSecret: config.Camo.SharedSecret, 63 + Sanitizer: markup.NewSanitizer(), 60 64 } 61 65 62 66 p := &Pages{ 63 67 mu: sync.RWMutex{}, 64 - t: make(map[string]*template.Template), 68 + cache: NewTmplCache[string, *template.Template](), 65 69 dev: config.Core.Dev, 66 70 avatar: config.Avatar, 67 - embedFS: Files, 68 71 rctx: rctx, 72 + resolver: res, 69 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 70 75 } 71 76 72 - // Initial load of all templates 73 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 74 82 75 83 return p 76 84 } 77 85 78 - func (p *Pages) loadAllTemplates() { 79 - templates := make(map[string]*template.Template) 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 + 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 80 96 var fragmentPaths []string 81 - 82 - // Use embedded FS for initial loading 83 - // First, collect all fragment paths 84 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 85 98 if err != nil { 86 99 return err ··· 94 107 if !strings.Contains(path, "fragments/") { 95 108 return nil 96 109 } 97 - name := strings.TrimPrefix(path, "templates/") 98 - name = strings.TrimSuffix(name, ".html") 99 - tmpl, err := template.New(name). 100 - Funcs(p.funcMap()). 101 - ParseFS(p.embedFS, path) 102 - if err != nil { 103 - log.Fatalf("setting up fragment: %v", err) 104 - } 105 - templates[name] = tmpl 106 110 fragmentPaths = append(fragmentPaths, path) 107 - log.Printf("loaded fragment: %s", name) 108 111 return nil 109 112 }) 110 113 if err != nil { 111 - log.Fatalf("walking template dir for fragments: %v", err) 114 + return nil, err 112 115 } 113 116 114 - // Then walk through and setup the rest of the templates 115 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 117 + return fragmentPaths, nil 118 + } 119 + 120 + func (p *Pages) fragments() (*template.Template, error) { 121 + fragmentPaths, err := p.fragmentPaths() 122 + if err != nil { 123 + return nil, err 124 + } 125 + 126 + funcs := p.funcMap() 127 + 128 + // parse all fragments together 129 + allFragments := template.New("").Funcs(funcs) 130 + for _, f := range fragmentPaths { 131 + name := p.pathToName(f) 132 + 133 + pf, err := template.New(name). 134 + Funcs(funcs). 135 + ParseFS(p.embedFS, f) 116 136 if err != nil { 117 - return err 118 - } 119 - if d.IsDir() { 120 - return nil 121 - } 122 - if !strings.HasSuffix(path, "html") { 123 - return nil 124 - } 125 - // Skip fragments as they've already been loaded 126 - if strings.Contains(path, "fragments/") { 127 - return nil 128 - } 129 - // Skip layouts 130 - if strings.Contains(path, "layouts/") { 131 - return nil 137 + return nil, err 132 138 } 133 - name := strings.TrimPrefix(path, "templates/") 134 - name = strings.TrimSuffix(name, ".html") 135 - // Add the page template on top of the base 136 - allPaths := []string{} 137 - allPaths = append(allPaths, "templates/layouts/*.html") 138 - allPaths = append(allPaths, fragmentPaths...) 139 - allPaths = append(allPaths, path) 140 - tmpl, err := template.New(name). 141 - Funcs(p.funcMap()). 142 - ParseFS(p.embedFS, allPaths...) 139 + 140 + allFragments, err = allFragments.AddParseTree(name, pf.Tree) 143 141 if err != nil { 144 - return fmt.Errorf("setting up template: %w", err) 142 + return nil, err 145 143 } 146 - templates[name] = tmpl 147 - log.Printf("loaded template: %s", name) 148 - return nil 149 - }) 150 - if err != nil { 151 - log.Fatalf("walking template dir: %v", err) 152 144 } 153 145 154 - log.Printf("total templates loaded: %d", len(templates)) 155 - p.mu.Lock() 156 - defer p.mu.Unlock() 157 - p.t = templates 146 + return allFragments, nil 158 147 } 159 148 160 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 161 - func (p *Pages) loadTemplateFromDisk(name string) error { 162 - if !p.dev { 163 - return nil 149 + // parse without memoization 150 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 151 + paths, err := p.fragmentPaths() 152 + if err != nil { 153 + return nil, err 164 154 } 165 - 166 - log.Printf("reloading template from disk: %s", name) 155 + for _, s := range stack { 156 + paths = append(paths, p.nameToPath(s)) 157 + } 167 158 168 - // Find all fragments first 169 - var fragmentPaths []string 170 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 171 - if err != nil { 172 - return err 173 - } 174 - if d.IsDir() { 175 - return nil 176 - } 177 - if !strings.HasSuffix(path, ".html") { 178 - return nil 179 - } 180 - if !strings.Contains(path, "fragments/") { 181 - return nil 182 - } 183 - fragmentPaths = append(fragmentPaths, path) 184 - return nil 185 - }) 159 + funcs := p.funcMap() 160 + top := stack[len(stack)-1] 161 + parsed, err := template.New(top). 162 + Funcs(funcs). 163 + ParseFS(p.embedFS, paths...) 186 164 if err != nil { 187 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 165 + return nil, err 188 166 } 189 167 190 - // Find the template path on disk 191 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 192 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 193 - return fmt.Errorf("template not found on disk: %s", name) 168 + return parsed, nil 169 + } 170 + 171 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 172 + key := strings.Join(stack, "|") 173 + 174 + // never cache in dev mode 175 + if cached, exists := p.cache.Get(key); !p.dev && exists { 176 + return cached, nil 194 177 } 195 178 196 - // Create a new template 197 - tmpl := template.New(name).Funcs(p.funcMap()) 198 - 199 - // Parse layouts 200 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 201 - layouts, err := filepath.Glob(layoutGlob) 179 + result, err := p.rawParse(stack...) 202 180 if err != nil { 203 - return fmt.Errorf("finding layout templates: %w", err) 181 + return nil, err 204 182 } 205 183 206 - // Create paths for parsing 207 - allFiles := append(layouts, fragmentPaths...) 208 - allFiles = append(allFiles, templatePath) 184 + p.cache.Set(key, result) 185 + return result, nil 186 + } 209 187 210 - // Parse all templates 211 - tmpl, err = tmpl.ParseFiles(allFiles...) 212 - if err != nil { 213 - return fmt.Errorf("parsing template files: %w", err) 188 + func (p *Pages) parseBase(top string) (*template.Template, error) { 189 + stack := []string{ 190 + "layouts/base", 191 + top, 214 192 } 193 + return p.parse(stack...) 194 + } 215 195 216 - // Update the template in the map 217 - p.mu.Lock() 218 - defer p.mu.Unlock() 219 - p.t[name] = tmpl 220 - log.Printf("template reloaded from disk: %s", name) 221 - return nil 196 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 197 + stack := []string{ 198 + "layouts/base", 199 + "layouts/repobase", 200 + top, 201 + } 202 + return p.parse(stack...) 222 203 } 223 204 224 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 225 - // In dev mode, reload the template from disk before executing 226 - if p.dev { 227 - if err := p.loadTemplateFromDisk(templateName); err != nil { 228 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 229 - // Continue with the existing template 230 - } 205 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 206 + stack := []string{ 207 + "layouts/base", 208 + "layouts/profilebase", 209 + top, 231 210 } 211 + return p.parse(stack...) 212 + } 232 213 233 - p.mu.RLock() 234 - defer p.mu.RUnlock() 235 - tmpl, exists := p.t[templateName] 236 - if !exists { 237 - return fmt.Errorf("template not found: %s", templateName) 214 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 215 + tpl, err := p.parse(name) 216 + if err != nil { 217 + return err 238 218 } 239 219 240 - if base == "" { 241 - return tmpl.Execute(w, params) 242 - } else { 243 - return tmpl.ExecuteTemplate(w, base, params) 244 - } 220 + return tpl.Execute(w, params) 245 221 } 246 222 247 223 func (p *Pages) execute(name string, w io.Writer, params any) error { 248 - return p.executeOrReload(name, w, "layouts/base", params) 249 - } 224 + tpl, err := p.parseBase(name) 225 + if err != nil { 226 + return err 227 + } 250 228 251 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "", params) 229 + return tpl.ExecuteTemplate(w, "layouts/base", params) 253 230 } 254 231 255 232 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "layouts/repobase", params) 233 + tpl, err := p.parseRepoBase(name) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return tpl.ExecuteTemplate(w, "layouts/base", params) 239 + } 240 + 241 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 242 + tpl, err := p.parseProfileBase(name) 243 + if err != nil { 244 + return err 245 + } 246 + 247 + return tpl.ExecuteTemplate(w, "layouts/base", params) 248 + } 249 + 250 + func (p *Pages) Favicon(w io.Writer) error { 251 + return p.executePlain("favicon", w, nil) 257 252 } 258 253 259 254 type LoginParams struct { 255 + ReturnUrl string 260 256 } 261 257 262 258 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 290 286 type TimelineParams struct { 291 287 LoggedInUser *oauth.User 292 288 Timeline []db.TimelineEvent 293 - DidHandleMap map[string]string 289 + Repos []db.Repo 294 290 } 295 291 296 292 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 297 - return p.execute("timeline", w, params) 293 + return p.execute("timeline/timeline", w, params) 294 + } 295 + 296 + type UserProfileSettingsParams struct { 297 + LoggedInUser *oauth.User 298 + Tabs []map[string]any 299 + Tab string 300 + } 301 + 302 + func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 303 + return p.execute("user/settings/profile", w, params) 298 304 } 299 305 300 - type SettingsParams struct { 306 + type UserKeysSettingsParams struct { 301 307 LoggedInUser *oauth.User 302 308 PubKeys []db.PublicKey 309 + Tabs []map[string]any 310 + Tab string 311 + } 312 + 313 + func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 314 + return p.execute("user/settings/keys", w, params) 315 + } 316 + 317 + type UserEmailsSettingsParams struct { 318 + LoggedInUser *oauth.User 303 319 Emails []db.Email 320 + Tabs []map[string]any 321 + Tab string 304 322 } 305 323 306 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 307 - return p.execute("settings", w, params) 324 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 325 + return p.execute("user/settings/emails", w, params) 326 + } 327 + 328 + type KnotBannerParams struct { 329 + Registrations []db.Registration 330 + } 331 + 332 + func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 333 + return p.executePlain("knots/fragments/banner", w, params) 308 334 } 309 335 310 336 type KnotsParams struct { ··· 318 344 319 345 type KnotParams struct { 320 346 LoggedInUser *oauth.User 321 - DidHandleMap map[string]string 322 347 Registration *db.Registration 323 348 Members []string 324 349 Repos map[string][]db.Repo ··· 330 355 } 331 356 332 357 type KnotListingParams struct { 333 - db.Registration 358 + *db.Registration 334 359 } 335 360 336 361 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 337 362 return p.executePlain("knots/fragments/knotListing", w, params) 338 363 } 339 364 340 - type KnotListingFullParams struct { 341 - Registrations []db.Registration 342 - } 343 - 344 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 345 - return p.executePlain("knots/fragments/knotListingFull", w, params) 346 - } 347 - 348 - type KnotSecretParams struct { 349 - Secret string 350 - } 351 - 352 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 353 - return p.executePlain("knots/fragments/secret", w, params) 354 - } 355 - 356 365 type SpindlesParams struct { 357 366 LoggedInUser *oauth.User 358 367 Spindles []db.Spindle ··· 375 384 Spindle db.Spindle 376 385 Members []string 377 386 Repos map[string][]db.Repo 378 - DidHandleMap map[string]string 379 387 } 380 388 381 389 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 401 409 return p.execute("repo/fork", w, params) 402 410 } 403 411 404 - type ProfilePageParams struct { 412 + type ProfileCard struct { 413 + UserDid string 414 + UserHandle string 415 + FollowStatus db.FollowStatus 416 + Punchcard *db.Punchcard 417 + Profile *db.Profile 418 + Stats ProfileStats 419 + Active string 420 + } 421 + 422 + type ProfileStats struct { 423 + RepoCount int64 424 + StarredCount int64 425 + StringCount int64 426 + FollowersCount int64 427 + FollowingCount int64 428 + } 429 + 430 + func (p *ProfileCard) GetTabs() [][]any { 431 + tabs := [][]any{ 432 + {"overview", "overview", "square-chart-gantt", nil}, 433 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 434 + {"starred", "starred", "star", p.Stats.StarredCount}, 435 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 436 + } 437 + 438 + return tabs 439 + } 440 + 441 + type ProfileOverviewParams struct { 405 442 LoggedInUser *oauth.User 406 443 Repos []db.Repo 407 444 CollaboratingRepos []db.Repo 408 445 ProfileTimeline *db.ProfileTimeline 409 - Card ProfileCard 410 - Punchcard db.Punchcard 446 + Card *ProfileCard 447 + Active string 448 + } 411 449 412 - DidHandleMap map[string]string 450 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 451 + params.Active = "overview" 452 + return p.executeProfile("user/overview", w, params) 413 453 } 414 454 415 - type ProfileCard struct { 416 - UserDid string 417 - UserHandle string 418 - FollowStatus db.FollowStatus 419 - Followers int 420 - Following int 455 + type ProfileReposParams struct { 456 + LoggedInUser *oauth.User 457 + Repos []db.Repo 458 + Card *ProfileCard 459 + Active string 460 + } 421 461 422 - Profile *db.Profile 462 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 463 + params.Active = "repos" 464 + return p.executeProfile("user/repos", w, params) 423 465 } 424 466 425 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 426 - return p.execute("user/profile", w, params) 467 + type ProfileStarredParams struct { 468 + LoggedInUser *oauth.User 469 + Repos []db.Repo 470 + Card *ProfileCard 471 + Active string 472 + } 473 + 474 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 475 + params.Active = "starred" 476 + return p.executeProfile("user/starred", w, params) 477 + } 478 + 479 + type ProfileStringsParams struct { 480 + LoggedInUser *oauth.User 481 + Strings []db.String 482 + Card *ProfileCard 483 + Active string 484 + } 485 + 486 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 487 + params.Active = "strings" 488 + return p.executeProfile("user/strings", w, params) 489 + } 490 + 491 + type FollowCard struct { 492 + UserDid string 493 + FollowStatus db.FollowStatus 494 + FollowersCount int64 495 + FollowingCount int64 496 + Profile *db.Profile 427 497 } 428 498 429 - type ReposPageParams struct { 499 + type ProfileFollowersParams struct { 430 500 LoggedInUser *oauth.User 431 - Repos []db.Repo 432 - Card ProfileCard 501 + Followers []FollowCard 502 + Card *ProfileCard 503 + Active string 504 + } 433 505 434 - DidHandleMap map[string]string 506 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 507 + params.Active = "overview" 508 + return p.executeProfile("user/followers", w, params) 435 509 } 436 510 437 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 438 - return p.execute("user/repos", w, params) 511 + type ProfileFollowingParams struct { 512 + LoggedInUser *oauth.User 513 + Following []FollowCard 514 + Card *ProfileCard 515 + Active string 516 + } 517 + 518 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 519 + params.Active = "overview" 520 + return p.executeProfile("user/following", w, params) 439 521 } 440 522 441 523 type FollowFragmentParams struct { ··· 460 542 LoggedInUser *oauth.User 461 543 Profile *db.Profile 462 544 AllRepos []PinnedRepo 463 - DidHandleMap map[string]string 464 545 } 465 546 466 547 type PinnedRepo struct { ··· 495 576 } 496 577 497 578 type RepoIndexParams struct { 498 - LoggedInUser *oauth.User 499 - RepoInfo repoinfo.RepoInfo 500 - Active string 501 - TagMap map[string][]string 502 - CommitsTrunc []*object.Commit 503 - TagsTrunc []*types.TagReference 504 - BranchesTrunc []types.Branch 505 - ForkInfo *types.ForkInfo 579 + LoggedInUser *oauth.User 580 + RepoInfo repoinfo.RepoInfo 581 + Active string 582 + TagMap map[string][]string 583 + CommitsTrunc []*object.Commit 584 + TagsTrunc []*types.TagReference 585 + BranchesTrunc []types.Branch 586 + // ForkInfo *types.ForkInfo 506 587 HTMLReadme template.HTML 507 588 Raw bool 508 589 EmailToDidOrHandle map[string]string ··· 519 600 } 520 601 521 602 p.rctx.RepoInfo = params.RepoInfo 603 + p.rctx.RepoInfo.Ref = params.Ref 522 604 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 523 605 524 606 if params.ReadmeFileName != "" { 525 - var htmlString string 526 607 ext := filepath.Ext(params.ReadmeFileName) 527 608 switch ext { 528 609 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 529 - htmlString = p.rctx.Sanitize(htmlString) 530 - htmlString = p.rctx.RenderMarkdown(params.Readme) 531 610 params.Raw = false 532 - params.HTMLReadme = template.HTML(htmlString) 611 + htmlString := p.rctx.RenderMarkdown(params.Readme) 612 + sanitized := p.rctx.SanitizeDefault(htmlString) 613 + params.HTMLReadme = template.HTML(sanitized) 533 614 default: 534 615 params.Raw = true 535 616 } ··· 605 686 606 687 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 607 688 params.Active = "overview" 608 - return p.execute("repo/tree", w, params) 689 + return p.executeRepo("repo/tree", w, params) 609 690 } 610 691 611 692 type RepoBranchesParams struct { ··· 668 749 p.rctx.RepoInfo = params.RepoInfo 669 750 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 670 751 htmlString := p.rctx.RenderMarkdown(params.Contents) 671 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 752 + sanitized := p.rctx.SanitizeDefault(htmlString) 753 + params.RenderedContents = template.HTML(sanitized) 672 754 } 673 755 } 674 756 675 - if params.Lines < 5000 { 676 - c := params.Contents 677 - formatter := chromahtml.New( 678 - chromahtml.InlineCode(false), 679 - chromahtml.WithLineNumbers(true), 680 - chromahtml.WithLinkableLineNumbers(true, "L"), 681 - chromahtml.Standalone(false), 682 - chromahtml.WithClasses(true), 683 - ) 684 - 685 - lexer := lexers.Get(filepath.Base(params.Path)) 686 - if lexer == nil { 687 - lexer = lexers.Fallback 688 - } 757 + c := params.Contents 758 + formatter := chromahtml.New( 759 + chromahtml.InlineCode(false), 760 + chromahtml.WithLineNumbers(true), 761 + chromahtml.WithLinkableLineNumbers(true, "L"), 762 + chromahtml.Standalone(false), 763 + chromahtml.WithClasses(true), 764 + ) 689 765 690 - iterator, err := lexer.Tokenise(nil, c) 691 - if err != nil { 692 - return fmt.Errorf("chroma tokenize: %w", err) 693 - } 766 + lexer := lexers.Get(filepath.Base(params.Path)) 767 + if lexer == nil { 768 + lexer = lexers.Fallback 769 + } 694 770 695 - var code bytes.Buffer 696 - err = formatter.Format(&code, style, iterator) 697 - if err != nil { 698 - return fmt.Errorf("chroma format: %w", err) 699 - } 771 + iterator, err := lexer.Tokenise(nil, c) 772 + if err != nil { 773 + return fmt.Errorf("chroma tokenize: %w", err) 774 + } 700 775 701 - params.Contents = code.String() 776 + var code bytes.Buffer 777 + err = formatter.Format(&code, style, iterator) 778 + if err != nil { 779 + return fmt.Errorf("chroma format: %w", err) 702 780 } 703 781 782 + params.Contents = code.String() 704 783 params.Active = "overview" 705 784 return p.executeRepo("repo/blob", w, params) 706 785 } ··· 779 858 RepoInfo repoinfo.RepoInfo 780 859 Active string 781 860 Issues []db.Issue 782 - DidHandleMap map[string]string 783 861 Page pagination.Page 784 862 FilteringByOpen bool 785 863 } ··· 793 871 LoggedInUser *oauth.User 794 872 RepoInfo repoinfo.RepoInfo 795 873 Active string 796 - Issue db.Issue 874 + Issue *db.Issue 797 875 Comments []db.Comment 798 876 IssueOwnerHandle string 799 - DidHandleMap map[string]string 800 877 801 878 OrderedReactionKinds []db.ReactionKind 802 879 Reactions map[db.ReactionKind]int ··· 823 900 } else { 824 901 params.State = "closed" 825 902 } 826 - return p.execute("repo/issues/issue", w, params) 903 + return p.executeRepo("repo/issues/issue", w, params) 827 904 } 828 905 829 906 type RepoNewIssueParams struct { ··· 850 927 851 928 type SingleIssueCommentParams struct { 852 929 LoggedInUser *oauth.User 853 - DidHandleMap map[string]string 854 930 RepoInfo repoinfo.RepoInfo 855 931 Issue *db.Issue 856 932 Comment *db.Comment ··· 882 958 RepoInfo repoinfo.RepoInfo 883 959 Pulls []*db.Pull 884 960 Active string 885 - DidHandleMap map[string]string 886 961 FilteringBy db.PullState 887 962 Stacks map[string]db.Stack 888 963 Pipelines map[string]db.Pipeline ··· 915 990 LoggedInUser *oauth.User 916 991 RepoInfo repoinfo.RepoInfo 917 992 Active string 918 - DidHandleMap map[string]string 919 993 Pull *db.Pull 920 994 Stack db.Stack 921 995 AbandonedPulls []*db.Pull ··· 935 1009 936 1010 type RepoPullPatchParams struct { 937 1011 LoggedInUser *oauth.User 938 - DidHandleMap map[string]string 939 1012 RepoInfo repoinfo.RepoInfo 940 1013 Pull *db.Pull 941 1014 Stack db.Stack ··· 953 1026 954 1027 type RepoPullInterdiffParams struct { 955 1028 LoggedInUser *oauth.User 956 - DidHandleMap map[string]string 957 1029 RepoInfo repoinfo.RepoInfo 958 1030 Pull *db.Pull 959 1031 Round int ··· 1166 1238 return p.execute("strings/dashboard", w, params) 1167 1239 } 1168 1240 1241 + type StringTimelineParams struct { 1242 + LoggedInUser *oauth.User 1243 + Strings []db.String 1244 + } 1245 + 1246 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1247 + return p.execute("strings/timeline", w, params) 1248 + } 1249 + 1169 1250 type SingleStringParams struct { 1170 1251 LoggedInUser *oauth.User 1171 1252 ShowRendered bool ··· 1182 1263 if params.ShowRendered { 1183 1264 switch markup.GetFormat(params.String.Filename) { 1184 1265 case markup.FormatMarkdown: 1185 - p.rctx.RendererType = markup.RendererTypeDefault 1266 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1186 1267 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1187 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1268 + sanitized := p.rctx.SanitizeDefault(htmlString) 1269 + params.RenderedContents = template.HTML(sanitized) 1188 1270 } 1189 1271 } 1190 1272 ··· 1224 1306 1225 1307 sub, err := fs.Sub(Files, "static") 1226 1308 if err != nil { 1227 - log.Fatalf("no static dir found? that's crazy: %v", err) 1309 + p.logger.Error("no static dir found? that's crazy", "err", err) 1310 + panic(err) 1228 1311 } 1229 1312 // Custom handler to apply Cache-Control headers for font files 1230 1313 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1247 1330 func CssContentHash() string { 1248 1331 cssFile, err := Files.Open("static/tw.css") 1249 1332 if err != nil { 1250 - log.Printf("Error opening CSS file: %v", err) 1333 + slog.Debug("Error opening CSS file", "err", err) 1251 1334 return "" 1252 1335 } 1253 1336 defer cssFile.Close() 1254 1337 1255 1338 hasher := sha256.New() 1256 1339 if _, err := io.Copy(hasher, cssFile); err != nil { 1257 - log.Printf("Error hashing CSS file: %v", err) 1340 + slog.Debug("Error hashing CSS file", "err", err) 1258 1341 return "" 1259 1342 } 1260 1343 ··· 1267 1350 1268 1351 func (p *Pages) Error404(w io.Writer) error { 1269 1352 return p.execute("errors/404", w, nil) 1353 + } 1354 + 1355 + func (p *Pages) ErrorKnot404(w io.Writer) error { 1356 + return p.execute("errors/knot404", w, nil) 1270 1357 } 1271 1358 1272 1359 func (p *Pages) Error503(w io.Writer) error {
+2 -7
appview/pages/repoinfo/repoinfo.go
··· 78 78 func (r RepoInfo) TabMetadata() map[string]any { 79 79 meta := make(map[string]any) 80 80 81 - if r.Stats.PullCount.Open > 0 { 82 - meta["pulls"] = r.Stats.PullCount.Open 83 - } 84 - 85 - if r.Stats.IssueCount.Open > 0 { 86 - meta["issues"] = r.Stats.IssueCount.Open 87 - } 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 88 83 89 84 // more stuff? 90 85
+24 -4
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>404 &mdash; nothing like that here!</h1> 5 - <p> 6 - It seems we couldn't find what you were looking for. Sorry about that! 7 - </p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 + {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; page not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + go back 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 8 28 {{ end }}
+36 -3
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</p> 6 - {{ end }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 + {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 500 &mdash; internal server error 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + Something went wrong on our end. We've been notified and are working to fix the issue. 18 + </p> 19 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 + <div class="flex items-center gap-2"> 21 + {{ i "info" "w-4 h-4" }} 22 + <span class="font-medium">we're on it!</span> 23 + </div> 24 + <p class="mt-1">Our team has been automatically notified about this error.</p> 25 + </div> 26 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 + {{ i "refresh-cw" "w-4 h-4" }} 29 + try again 30 + </button> 31 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 + {{ i "home" "w-4 h-4" }} 33 + back to home 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 39 + {{ end }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>503 &mdash; unable to reach knot</h1> 5 - <p> 6 - We were unable to reach the knot hosting this repository. Try again 7 - later. 8 - </p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 + {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 503 &mdash; service unavailable 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 + {{ i "refresh-cw" "w-4 h-4" }} 22 + try again 23 + </button> 24 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 + back to timeline 27 + </a> 28 + </div> 29 + </div> 30 + </div> 31 + </div> 9 32 {{ end }}
+28
appview/pages/templates/errors/knot404.html
··· 1 + {{ define "title" }}404 &middot; tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 + {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; repository not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + back to timeline 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 + {{ end }}
+26
appview/pages/templates/favicon.html
··· 1 + {{ define "favicon" }} 2 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 + <style> 4 + .favicon-text { 5 + fill: #000000; 6 + stroke: none; 7 + } 8 + 9 + @media (prefers-color-scheme: dark) { 10 + .favicon-text { 11 + fill: #ffffff; 12 + stroke: none; 13 + } 14 + } 15 + </style> 16 + 17 + <g style="display:inline"> 18 + <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 + <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 + aria-label="tangled.sh" 21 + class="favicon-text" 22 + style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 + transform="translate(11.01 6.9)"/> 24 + </g> 25 + </svg> 26 + {{ end }}
+96 -32
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <div id="left-side" class="flex gap-2 items-center"> 7 - <h1 class="text-xl font-bold dark:text-white"> 8 - {{ .Registration.Domain }} 9 - </h1> 10 - <span class="text-gray-500 text-base"> 11 - {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 - </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 18 13 {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 14 {{ end }} 22 - </div> 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 18 + </span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 26 + {{ end }} 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 23 32 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 26 36 27 - {{ if .Members }} 28 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 - <div class="flex flex-col gap-2"> 30 - {{ block "knotMember" . }} {{ end }} 31 - </div> 32 - </section> 33 - {{ end }} 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 34 44 {{ end }} 35 45 36 - {{ define "knotMember" }} 46 + 47 + {{ define "member" }} 37 48 {{ range .Members }} 38 49 <div> 39 50 <div class="flex justify-between items-center"> 40 51 <div class="flex items-center gap-2"> 41 - {{ i "user" "size-4" }} 42 - {{ $user := index $.DidHandleMap . }} 43 - <a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a> 52 + {{ template "user/fragments/picHandleLink" . }} 53 + <span class="ml-2 font-mono text-gray-500">{{.}}</span> 44 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 45 58 </div> 46 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 47 60 {{ $repos := index $.Repos . }} 48 61 {{ range $repos }} 49 62 <div class="flex gap-2 items-center"> 50 63 {{ i "book-marked" "size-4" }} 51 - <a href="/{{ .Did }}/{{ .Name }}"> 64 + <a href="/{{ resolve .Did }}/{{ .Name }}"> 52 65 {{ .Name }} 53 66 </a> 54 67 </div> 55 68 {{ else }} 56 69 <div class="text-gray-500 dark:text-gray-400"> 57 - No repositories created yet. 70 + No repositories configured yet. 58 71 </div> 59 72 {{ end }} 60 73 </div> 61 74 </div> 62 75 {{ end }} 63 76 {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 1 {{ define "knots/fragments/addMemberModal" }} 2 2 <button 3 3 class="btn gap-2 group" 4 - title="Add member to this spindle" 4 + title="Add member to this knot" 5 5 popovertarget="add-member-{{ .Id }}" 6 6 popovertargetaction="toggle" 7 7 > ··· 20 20 21 21 {{ define "addKnotMemberPopover" }} 22 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 23 + hx-post="/knots/{{ .Domain }}/add" 24 24 hx-indicator="#spinner" 25 25 hx-swap="none" 26 26 class="flex flex-col gap-2" ··· 28 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 29 ADD MEMBER 30 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 32 <input 33 33 type="text" 34 34 id="member-did-{{ .Id }}" 35 - name="subject" 35 + name="member" 36 36 required 37 37 placeholder="@foo.bsky.social" 38 38 /> 39 39 <div class="flex gap-2 pt-2"> 40 - <button 40 + <button 41 41 type="button" 42 42 popovertarget="add-member-{{ .Id }}" 43 43 popovertargetaction="hide" ··· 54 54 </div> 55 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 56 </form> 57 - {{ end }} 58 - 57 + {{ end }}
+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 +
+57 -25
appview/pages/templates/knots/fragments/knotListing.html
··· 1 1 {{ define "knots/fragments/knotListing" }} 2 - <div 3 - id="knot-{{.Id}}" 4 - hx-swap-oob="true" 5 - class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 - {{ block "listLeftSide" . }} {{ end }} 7 - {{ block "listRightSide" . }} {{ end }} 2 + <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "knotLeftSide" . }} {{ end }} 4 + {{ block "knotRightSide" . }} {{ end }} 8 5 </div> 9 6 {{ end }} 10 7 11 - {{ define "listLeftSide" }} 8 + {{ define "knotLeftSide" }} 9 + {{ if .Registered }} 10 + <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 12 20 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 21 {{ i "hard-drive" "w-4 h-4" }} 14 - {{ if .Registered }} 15 - <a href="/knots/{{ .Domain }}"> 16 - {{ .Domain }} 17 - </a> 18 - {{ else }} 19 - {{ .Domain }} 20 - {{ end }} 22 + {{ .Domain }} 21 23 <span class="text-gray-500"> 22 24 {{ template "repo/fragments/shortTimeAgo" .Created }} 23 25 </span> 24 26 </div> 27 + {{ end }} 25 28 {{ end }} 26 29 27 - {{ define "listRightSide" }} 30 + {{ define "knotRightSide" }} 28 31 <div id="right-side" class="flex gap-2"> 29 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 30 - {{ if .Registered }} 31 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 32 37 {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsReadOnly }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} read-only 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 33 45 {{ else }} 34 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 35 - {{ block "initializeButton" . }} {{ end }} 46 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 + {{ i "shield-off" "w-4 h-4" }} unverified 48 + </span> 49 + {{ block "knotRetryButton" . }} {{ end }} 50 + {{ block "knotDeleteButton" . }} {{ end }} 36 51 {{ end }} 37 52 </div> 38 53 {{ end }} 39 54 40 - {{ define "initializeButton" }} 55 + {{ define "knotDeleteButton" }} 56 + <button 57 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 + title="Delete knot" 59 + hx-delete="/knots/{{ .Domain }}" 60 + hx-swap="outerHTML" 61 + hx-target="#knot-{{.Id}}" 62 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 41 72 <button 42 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 43 - hx-post="/knots/{{ .Domain }}/init" 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 44 76 hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 45 78 > 46 - {{ i "square-play" "w-5 h-5" }} 47 - <span class="hidden md:inline">initialize</span> 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 48 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 82 </button> 50 83 {{ end }} 51 -
-18
appview/pages/templates/knots/fragments/knotListingFull.html
··· 1 - {{ define "knots/fragments/knotListingFull" }} 2 - <section 3 - id="knot-listing-full" 4 - hx-swap-oob="true" 5 - class="rounded w-full flex flex-col gap-2"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 7 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 8 - {{ range $knot := .Registrations }} 9 - {{ template "knots/fragments/knotListing" . }} 10 - {{ else }} 11 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 12 - no knots registered yet 13 - </div> 14 - {{ end }} 15 - </div> 16 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 17 - </section> 18 - {{ end }}
-10
appview/pages/templates/knots/fragments/secret.html
··· 1 - {{ define "knots/fragments/secret" }} 2 - <div 3 - id="secret" 4 - hx-swap-oob="true" 5 - class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2> 7 - <p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p> 8 - <span class="font-mono overflow-x">{{ .Secret }}</span> 9 - </div> 10 - {{ end }}
+23 -8
appview/pages/templates/knots/index.html
··· 8 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 9 <div class="flex flex-col gap-6"> 10 10 {{ block "about" . }} {{ end }} 11 - {{ template "knots/fragments/knotListingFull" . }} 11 + {{ block "list" . }} {{ end }} 12 12 {{ block "register" . }} {{ end }} 13 13 </div> 14 14 </section> ··· 27 27 </section> 28 28 {{ end }} 29 29 30 + {{ define "list" }} 31 + <section class="rounded w-full flex flex-col gap-2"> 32 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 33 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 34 + {{ range $registration := .Registrations }} 35 + {{ template "knots/fragments/knotListing" . }} 36 + {{ else }} 37 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 38 + no knots registered yet 39 + </div> 40 + {{ end }} 41 + </div> 42 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 43 + </section> 44 + {{ end }} 45 + 30 46 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 47 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 32 48 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 33 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 49 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 34 50 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 51 + hx-post="/knots/register" 52 + class="max-w-2xl mb-2 space-y-4" 37 53 hx-indicator="#register-button" 38 54 hx-swap="none" 39 55 > ··· 53 69 > 54 70 <span class="inline-flex items-center gap-2"> 55 71 {{ i "plus" "w-4 h-4" }} 56 - generate 72 + register 57 73 </span> 58 74 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 75 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 77 </button> 62 78 </div> 63 79 64 - <div id="registration-error" class="error dark:text-red-400"></div> 80 + <div id="register-error" class="error dark:text-red-400"></div> 65 81 </form> 66 82 67 - <div id="secret"></div> 68 83 </section> 69 84 {{ end }}
+2 -14
appview/pages/templates/layouts/base.html
··· 17 17 <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 18 {{ block "topbarLayout" . }} 19 19 <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 - {{ template "layouts/topbar" . }} 20 + {{ template "layouts/fragments/topbar" . }} 21 21 </header> 22 22 {{ end }} 23 23 24 24 {{ block "mainLayout" . }} 25 25 <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 26 {{ block "contentLayout" . }} 27 - <div class="col-span-1 md:col-span-2"> 28 - {{ block "contentLeft" . }} {{ end }} 29 - </div> 30 27 <main class="col-span-1 md:col-span-8"> 31 28 {{ block "content" . }}{{ end }} 32 29 </main> 33 - <div class="col-span-1 md:col-span-2"> 34 - {{ block "contentRight" . }} {{ end }} 35 - </div> 36 30 {{ end }} 37 31 38 32 {{ block "contentAfterLayout" . }} 39 - <div class="col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 33 <main class="col-span-1 md:col-span-8"> 43 34 {{ block "contentAfter" . }}{{ end }} 44 35 </main> 45 - <div class="col-span-1 md:col-span-2"> 46 - {{ block "contentAfterRight" . }} {{ end }} 47 - </div> 48 36 {{ end }} 49 37 </div> 50 38 {{ end }} 51 39 52 40 {{ block "footerLayout" . }} 53 41 <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 - {{ template "layouts/footer" . }} 42 + {{ template "layouts/fragments/footer" . }} 55 43 </footer> 56 44 {{ end }} 57 45 </body>
-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 }}
+87
appview/pages/templates/layouts/fragments/topbar.html
··· 1 + {{ define "layouts/fragments/topbar" }} 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 + <div class="flex justify-between p-0 items-center"> 4 + <div id="left-items"> 5 + <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 + tangled<sub>alpha</sub> 7 + </a> 8 + </div> 9 + 10 + <div id="right-items" class="flex items-center gap-2"> 11 + {{ with .LoggedInUser }} 12 + {{ block "newButton" . }} {{ end }} 13 + {{ block "dropDown" . }} {{ end }} 14 + {{ else }} 15 + <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 20 + {{ end }} 21 + </div> 22 + </div> 23 + </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 31 + {{ end }} 32 + 33 + {{ define "newButton" }} 34 + <details class="relative inline-block text-left nav-dropdown"> 35 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 + {{ i "plus" "w-4 h-4" }} new 37 + </summary> 38 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 + <a href="/repo/new" class="flex items-center gap-2"> 40 + {{ i "book-plus" "w-4 h-4" }} 41 + new repository 42 + </a> 43 + <a href="/strings/new" class="flex items-center gap-2"> 44 + {{ i "line-squiggle" "w-4 h-4" }} 45 + new string 46 + </a> 47 + </div> 48 + </details> 49 + {{ end }} 50 + 51 + {{ define "dropDown" }} 52 + <details class="relative inline-block text-left nav-dropdown"> 53 + <summary 54 + class="cursor-pointer list-none flex items-center" 55 + > 56 + {{ $user := didOrHandle .Did .Handle }} 57 + {{ template "user/fragments/picHandle" $user }} 58 + </summary> 59 + <div 60 + class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 61 + > 62 + <a href="/{{ $user }}">profile</a> 63 + <a href="/{{ $user }}?tab=repos">repositories</a> 64 + <a href="/{{ $user }}?tab=strings">strings</a> 65 + <a href="/knots">knots</a> 66 + <a href="/spindles">spindles</a> 67 + <a href="/settings">settings</a> 68 + <a href="#" 69 + hx-post="/logout" 70 + hx-swap="none" 71 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 72 + logout 73 + </a> 74 + </div> 75 + </details> 76 + 77 + <script> 78 + document.addEventListener('click', function(event) { 79 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 + dropdowns.forEach(function(dropdown) { 81 + if (!dropdown.contains(event.target)) { 82 + dropdown.removeAttribute('open'); 83 + } 84 + }); 85 + }); 86 + </script> 87 + {{ end }}
+104
appview/pages/templates/layouts/profilebase.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + <div class="md:col-span-3 order-1 md:order-1"> 15 + <div class="flex flex-col gap-4"> 16 + {{ template "user/fragments/profileCard" .Card }} 17 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 18 + </div> 19 + </div> 20 + {{ block "profileContent" . }} {{ end }} 21 + </div> 22 + </section> 23 + {{ end }} 24 + 25 + {{ define "profileTabs" }} 26 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 27 + <div class="flex z-60"> 28 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 29 + {{ $tabs := .Card.GetTabs }} 30 + {{ $tabmeta := dict "x" "y" }} 31 + {{ range $item := $tabs }} 32 + {{ $key := index $item 0 }} 33 + {{ $value := index $item 1 }} 34 + {{ $icon := index $item 2 }} 35 + {{ $meta := index $item 3 }} 36 + <a 37 + href="?tab={{ $value }}" 38 + class="relative -mr-px group no-underline hover:no-underline" 39 + hx-boost="true"> 40 + <div 41 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 42 + {{ if eq $.Active $key }} 43 + {{ $activeTabStyles }} 44 + {{ else }} 45 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 46 + {{ end }} 47 + "> 48 + <span class="flex items-center justify-center"> 49 + {{ i $icon "w-4 h-4 mr-2" }} 50 + {{ $key }} 51 + {{ if $meta }} 52 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 53 + {{ end }} 54 + </span> 55 + </div> 56 + </a> 57 + {{ end }} 58 + </div> 59 + </nav> 60 + {{ end }} 61 + 62 + {{ define "punchcard" }} 63 + {{ $now := now }} 64 + <div> 65 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 66 + PUNCHCARD 67 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 68 + {{ .Total | int64 | commaFmt }} commits 69 + </span> 70 + </p> 71 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 72 + {{ range .Punches }} 73 + {{ $count := .Count }} 74 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 75 + {{ if lt $count 1 }} 76 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 77 + {{ else if lt $count 2 }} 78 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 79 + {{ else if lt $count 4 }} 80 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 81 + {{ else if lt $count 8 }} 82 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 83 + {{ else }} 84 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 85 + {{ end }} 86 + 87 + {{ if .Date.After $now }} 88 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 89 + {{ end }} 90 + <div class="w-full h-full flex justify-center items-center"> 91 + <div 92 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 93 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 94 + </div> 95 + </div> 96 + {{ end }} 97 + </div> 98 + </div> 99 + {{ end }} 100 + 101 + {{ define "layouts/profilebase" }} 102 + {{ template "layouts/base" . }} 103 + {{ end }} 104 +
+18 -27
appview/pages/templates/layouts/repobase.html
··· 5 5 {{ if .RepoInfo.Source }} 6 6 <p class="text-sm"> 7 7 <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1"}} 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 9 forked from 10 10 {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 11 <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> ··· 20 20 </div> 21 21 22 22 <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 23 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 24 - {{ if .RepoInfo.DisableFork }} 25 - <button 26 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 - disabled 28 - title="Empty repositories cannot be forked" 29 - > 30 - {{ i "git-fork" "w-4 h-4" }} 31 - fork 32 - </button> 33 - {{ else }} 34 - <a 35 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 - hx-boost="true" 37 - href="/{{ .RepoInfo.FullName }}/fork" 38 - > 39 - {{ i "git-fork" "w-4 h-4" }} 40 - fork 41 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 - </a> 43 - {{ end }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 44 39 </div> 45 40 </div> 46 41 {{ template "repo/fragments/repoDescription" . }} ··· 76 71 <span class="flex items-center justify-center"> 77 72 {{ i $icon "w-4 h-4 mr-2" }} 78 73 {{ $key }} 79 - {{ if not (isNil $meta) }} 80 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 74 + {{ if $meta }} 75 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 81 76 {{ end }} 82 77 </span> 83 78 </div> ··· 93 88 {{ block "repoAfter" . }}{{ end }} 94 89 </section> 95 90 {{ end }} 96 - 97 - {{ define "layouts/repobase" }} 98 - {{ template "layouts/base" . }} 99 - {{ end }}
-69
appview/pages/templates/layouts/topbar.html
··· 1 - {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="flex justify-between p-0 items-center"> 4 - <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - 10 - <div id="right-items" class="flex items-center gap-2"> 11 - {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ block "dropDown" . }} {{ end }} 14 - {{ else }} 15 - <a href="/login">login</a> 16 - <span class="text-gray-500 dark:text-gray-400">or</span> 17 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 - join now {{ i "arrow-right" "size-4" }} 19 - </a> 20 - {{ end }} 21 - </div> 22 - </div> 23 - </nav> 24 - {{ end }} 25 - 26 - {{ define "newButton" }} 27 - <details class="relative inline-block text-left"> 28 - <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 30 - </summary> 31 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 - <a href="/repo/new" class="flex items-center gap-2"> 33 - {{ i "book-plus" "w-4 h-4" }} 34 - new repository 35 - </a> 36 - <a href="/strings/new" class="flex items-center gap-2"> 37 - {{ i "line-squiggle" "w-4 h-4" }} 38 - new string 39 - </a> 40 - </div> 41 - </details> 42 - {{ end }} 43 - 44 - {{ define "dropDown" }} 45 - <details class="relative inline-block text-left"> 46 - <summary 47 - class="cursor-pointer list-none flex items-center" 48 - > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 51 - </summary> 52 - <div 53 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 54 - > 55 - <a href="/{{ $user }}">profile</a> 56 - <a href="/{{ $user }}?tab=repos">repositories</a> 57 - <a href="/strings/{{ $user }}">strings</a> 58 - <a href="/knots">knots</a> 59 - <a href="/spindles">spindles</a> 60 - <a href="/settings">settings</a> 61 - <a href="#" 62 - hx-post="/logout" 63 - hx-swap="none" 64 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 65 - logout 66 - </a> 67 - </div> 68 - </details> 69 - {{ end }}
+3 -3
appview/pages/templates/repo/commit.html
··· 81 81 82 82 {{ define "topbarLayout" }} 83 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 - {{ template "layouts/topbar" . }} 84 + {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 ··· 106 106 107 107 {{ define "footerLayout" }} 108 108 <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 109 + {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }} 112 112 ··· 118 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 120 </div> 121 - <div class="sticky top-0 flex-grow max-h-screen"> 121 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 122 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 123 </div> 124 124 {{end}}
+3 -3
appview/pages/templates/repo/compare/compare.html
··· 12 12 13 13 {{ define "topbarLayout" }} 14 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 15 + {{ template "layouts/fragments/topbar" . }} 16 16 </header> 17 17 {{ end }} 18 18 ··· 37 37 38 38 {{ define "footerLayout" }} 39 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 40 + {{ template "layouts/fragments/footer" . }} 41 41 </footer> 42 42 {{ end }} 43 43 ··· 49 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 51 </div> 52 - <div class="sticky top-0 flex-grow max-h-screen"> 52 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 54 </div> 55 55 {{end}}
+5 -7
appview/pages/templates/repo/empty.html
··· 32 32 <div class="py-6 w-fit flex flex-col gap-4"> 33 33 <p>This is an empty repository. To get started:</p> 34 34 {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 - <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 - <p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p> 37 - <p><span class="{{$bullet}}">3</span>Push!</p> 35 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 38 40 </div> 39 41 </div> 40 42 {{ else }} ··· 42 44 {{ end }} 43 45 </main> 44 46 {{ end }} 45 - 46 - {{ define "repoAfter" }} 47 - {{ template "repo/fragments/cloneInstructions" . }} 48 - {{ end }}
+8 -2
appview/pages/templates/repo/fork.html
··· 5 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 9 <fieldset class="space-y-3"> 10 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 11 <div class="space-y-2"> ··· 30 30 </fieldset> 31 31 32 32 <div class="space-y-2"> 33 - <button type="submit" class="btn">fork repo</button> 33 + <button type="submit" class="btn-create flex items-center gap-2"> 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork repo 36 + <span id="spinner" class="group"> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </span> 39 + </button> 34 40 <div id="repo" class="error"></div> 35 41 </div> 36 42 </form>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
+29 -83
appview/pages/templates/repo/fragments/diff.html
··· 13 13 <div class="flex flex-col gap-4"> 14 14 {{ range $idx, $hunk := $diff }} 15 15 {{ with $hunk }} 16 - <section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 17 - <div id="file-{{ .Name.New }}"> 18 - <div id="diff-file"> 19 - <details open> 20 - <summary class="list-none cursor-pointer sticky top-0"> 21 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 22 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 23 - <div class="flex gap-1 items-center"> 24 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 25 - {{ if .IsNew }} 26 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 27 - {{ else if .IsDelete }} 28 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 29 - {{ else if .IsCopy }} 30 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 31 - {{ else if .IsRename }} 32 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 33 - {{ else }} 34 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 35 - {{ end }} 36 - 37 - {{ template "repo/fragments/diffStatPill" .Stats }} 38 - </div> 39 - 40 - <div class="flex gap-2 items-center overflow-x-auto"> 41 - {{ if .IsDelete }} 42 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 43 - {{ .Name.Old }} 44 - </a> 45 - {{ else if (or .IsCopy .IsRename) }} 46 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 47 - {{ .Name.Old }} 48 - </a> 49 - {{ i "arrow-right" "w-4 h-4" }} 50 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 51 - {{ .Name.New }} 52 - </a> 53 - {{ else }} 54 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 55 - {{ .Name.New }} 56 - </a> 57 - {{ end }} 58 - </div> 59 - </div> 60 - 61 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 62 - <div id="right-side-items" class="p-2 flex items-center"> 63 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 64 - {{ if gt $idx 0 }} 65 - {{ $prev := index $diff (sub $idx 1) }} 66 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 67 - {{ end }} 68 - 69 - {{ if lt $idx $last }} 70 - {{ $next := index $diff (add $idx 1) }} 71 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 72 - {{ end }} 73 - </div> 74 - 75 - </div> 76 - </summary> 16 + <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 17 + <summary class="list-none cursor-pointer sticky top-0"> 18 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 19 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 20 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 21 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 22 + {{ template "repo/fragments/diffStatPill" .Stats }} 77 23 78 - <div class="transition-all duration-700 ease-in-out"> 79 - {{ if .IsDelete }} 80 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 81 - This file has been deleted. 82 - </p> 83 - {{ else if .IsCopy }} 84 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 85 - This file has been copied. 86 - </p> 87 - {{ else if .IsBinary }} 88 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 89 - This is a binary file and will not be displayed. 90 - </p> 91 - {{ else }} 92 - {{ if $isSplit }} 93 - {{- template "repo/fragments/splitDiff" .Split -}} 24 + <div class="flex gap-2 items-center overflow-x-auto"> 25 + {{ if .IsDelete }} 26 + {{ .Name.Old }} 27 + {{ else if (or .IsCopy .IsRename) }} 28 + {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 94 29 {{ else }} 95 - {{- template "repo/fragments/unifiedDiff" . -}} 30 + {{ .Name.New }} 96 31 {{ end }} 97 - {{- end -}} 32 + </div> 98 33 </div> 34 + </div> 35 + </summary> 99 36 100 - </details> 101 - 37 + <div class="transition-all duration-700 ease-in-out"> 38 + {{ if .IsBinary }} 39 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 40 + This is a binary file and will not be displayed. 41 + </p> 42 + {{ else }} 43 + {{ if $isSplit }} 44 + {{- template "repo/fragments/splitDiff" .Split -}} 45 + {{ else }} 46 + {{- template "repo/fragments/unifiedDiff" . -}} 47 + {{ end }} 48 + {{- end -}} 102 49 </div> 103 - </div> 104 - </section> 50 + </details> 105 51 {{ end }} 106 52 {{ end }} 107 53 </div>
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+4 -4
appview/pages/templates/repo/fragments/fileTree.html
··· 3 3 <details open> 4 4 <summary class="cursor-pointer list-none pt-1"> 5 5 <span class="tree-directory inline-flex items-center gap-2 "> 6 - {{ i "folder" "size-4 fill-current" }} 7 - <span class="filename text-black dark:text-white">{{ .Name }}</span> 6 + {{ i "folder" "flex-shrink-0 size-4 fill-current" }} 7 + <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 8 8 </span> 9 9 </summary> 10 10 <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> ··· 15 15 </details> 16 16 {{ else if .Name }} 17 17 <div class="tree-file flex items-center gap-2 pt-1"> 18 - {{ i "file" "size-4" }} 19 - <a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 18 + {{ i "file" "flex-shrink-0 size-4" }} 19 + <a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 20 </div> 21 21 {{ else }} 22 22 {{ range $child := .Children }}
+44 -69
appview/pages/templates/repo/fragments/interdiff.html
··· 10 10 <div class="flex flex-col gap-4"> 11 11 {{ range $idx, $hunk := $diff }} 12 12 {{ with $hunk }} 13 - <section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 - <div id="file-{{ .Name }}"> 15 - <div id="diff-file"> 16 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 17 - <summary class="list-none cursor-pointer sticky top-0"> 18 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 19 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 20 - <div class="flex gap-1 items-center" style="direction: ltr;"> 21 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 22 - {{ if .Status.IsOk }} 23 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 24 - {{ else if .Status.IsUnchanged }} 25 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 26 - {{ else if .Status.IsOnlyInOne }} 27 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 28 - {{ else if .Status.IsOnlyInTwo }} 29 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 30 - {{ else if .Status.IsRebased }} 31 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 32 - {{ else }} 33 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 34 - {{ end }} 35 - </div> 36 - 37 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 38 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 39 - {{ .Name }} 40 - </a> 41 - </div> 42 - </div> 43 - 44 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 45 - <div id="right-side-items" class="p-2 flex items-center"> 46 - <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 47 - {{ if gt $idx 0 }} 48 - {{ $prev := index $diff (sub $idx 1) }} 49 - <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 50 - {{ end }} 51 - 52 - {{ if lt $idx $last }} 53 - {{ $next := index $diff (add $idx 1) }} 54 - <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 55 - {{ end }} 56 - </div> 57 - 13 + <details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 + <summary class="list-none cursor-pointer sticky top-0"> 15 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 16 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 17 + <div class="flex gap-1 items-center" style="direction: ltr;"> 18 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 19 + {{ if .Status.IsOk }} 20 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 21 + {{ else if .Status.IsUnchanged }} 22 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 23 + {{ else if .Status.IsOnlyInOne }} 24 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 25 + {{ else if .Status.IsOnlyInTwo }} 26 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 27 + {{ else if .Status.IsRebased }} 28 + <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 29 + {{ else }} 30 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 31 + {{ end }} 58 32 </div> 59 - </summary> 60 33 61 - <div class="transition-all duration-700 ease-in-out"> 62 - {{ if .Status.IsUnchanged }} 63 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 64 - This file has not been changed. 65 - </p> 66 - {{ else if .Status.IsRebased }} 67 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 68 - This patch was likely rebased, as context lines do not match. 69 - </p> 70 - {{ else if .Status.IsError }} 71 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 72 - Failed to calculate interdiff for this file. 73 - </p> 74 - {{ else }} 75 - {{ if $isSplit }} 76 - {{- template "repo/fragments/splitDiff" .Split -}} 77 - {{ else }} 78 - {{- template "repo/fragments/unifiedDiff" . -}} 79 - {{ end }} 80 - {{- end -}} 34 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div> 81 35 </div> 82 36 83 - </details> 37 + </div> 38 + </summary> 84 39 40 + <div class="transition-all duration-700 ease-in-out"> 41 + {{ if .Status.IsUnchanged }} 42 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 43 + This file has not been changed. 44 + </p> 45 + {{ else if .Status.IsRebased }} 46 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 47 + This patch was likely rebased, as context lines do not match. 48 + </p> 49 + {{ else if .Status.IsError }} 50 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 51 + Failed to calculate interdiff for this file. 52 + </p> 53 + {{ else }} 54 + {{ if $isSplit }} 55 + {{- template "repo/fragments/splitDiff" .Split -}} 56 + {{ else }} 57 + {{- template "repo/fragments/unifiedDiff" . -}} 58 + {{ end }} 59 + {{- end -}} 85 60 </div> 86 - </div> 87 - </section> 61 + 62 + </details> 88 63 {{ end }} 89 64 {{ end }} 90 65 </div>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 1 {{ define "repo/fragments/interdiffFiles" }} 2 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 3 + <section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 4 <div class="diff-stat"> 5 5 <div class="flex gap-2 items-center"> 6 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 1 {{ define "repo/fragments/repoDescription" }} 2 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 5 {{ else }} 6 6 <span class="italic">this repo has no description</span> 7 7 {{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 + {{ end }} 4 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 1 {{ define "repo/fragments/time" }} 6 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 3 {{ end }} 8 - 9 - {{ define "repo/fragments/shortTime" }} 10 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 - {{ end }} 12 - 13 - {{ define "repo/fragments/shortTimeAgo" }} 14 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 - {{ end }} 16 - 17 - {{ define "repo/fragments/duration" }} 18 - <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 - {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+91 -109
appview/pages/templates/repo/index.html
··· 14 14 {{ end }} 15 15 <div class="flex items-center justify-between pb-5"> 16 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-4"> 18 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1"> 17 + <div class="flex md:hidden items-center gap-2"> 18 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 22 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 25 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 26 </a> 27 + {{ template "repo/fragments/cloneDropdown" . }} 27 28 </div> 28 29 </div> 29 30 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 47 48 48 49 49 50 {{ define "branchSelector" }} 50 - <div class="flex gap-2 items-center items-stretch justify-center"> 51 - <select 52 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 53 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 54 - > 55 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 56 - {{ range .Branches }} 57 - <option 58 - value="{{ .Reference.Name }}" 59 - class="py-1" 60 - {{ if eq .Reference.Name $.Ref }} 61 - selected 62 - {{ end }} 63 - > 64 - {{ .Reference.Name }} 65 - </option> 66 - {{ end }} 67 - </optgroup> 68 - <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 69 - {{ range .Tags }} 70 - <option 71 - value="{{ .Reference.Name }}" 72 - class="py-1" 73 - {{ if eq .Reference.Name $.Ref }} 74 - selected 75 - {{ end }} 76 - > 77 - {{ .Reference.Name }} 78 - </option> 79 - {{ else }} 80 - <option class="py-1" disabled>no tags found</option> 81 - {{ end }} 82 - </optgroup> 83 - </select> 84 - <div class="flex items-center gap-2"> 85 - {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 86 - {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 87 - {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 88 - {{ $disabled := "" }} 89 - {{ $title := "" }} 90 - {{ if eq .ForkInfo.Status 0 }} 91 - {{ $disabled = "disabled" }} 92 - {{ $title = "This branch is not behind the upstream" }} 93 - {{ else if eq .ForkInfo.Status 2 }} 94 - {{ $disabled = "disabled" }} 95 - {{ $title = "This branch has conflicts that must be resolved" }} 96 - {{ else if eq .ForkInfo.Status 3 }} 97 - {{ $disabled = "disabled" }} 98 - {{ $title = "This branch does not exist on the upstream" }} 99 - {{ end }} 51 + <div class="flex gap-2 items-center justify-between w-full"> 52 + <div class="flex gap-2 items-center"> 53 + <select 54 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 55 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 56 + > 57 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 58 + {{ range .Branches }} 59 + <option 60 + value="{{ .Reference.Name }}" 61 + class="py-1" 62 + {{ if eq .Reference.Name $.Ref }} 63 + selected 64 + {{ end }} 65 + > 66 + {{ .Reference.Name }} 67 + </option> 68 + {{ end }} 69 + </optgroup> 70 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 71 + {{ range .Tags }} 72 + <option 73 + value="{{ .Reference.Name }}" 74 + class="py-1" 75 + {{ if eq .Reference.Name $.Ref }} 76 + selected 77 + {{ end }} 78 + > 79 + {{ .Reference.Name }} 80 + </option> 81 + {{ else }} 82 + <option class="py-1" disabled>no tags found</option> 83 + {{ end }} 84 + </optgroup> 85 + </select> 86 + <div class="flex items-center gap-2"> 87 + <a 88 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 89 + class="btn flex items-center gap-2 no-underline hover:no-underline" 90 + title="Compare branches or tags" 91 + > 92 + {{ i "git-compare" "w-4 h-4" }} 93 + </a> 94 + </div> 95 + </div> 100 96 101 - <button 102 - id="syncBtn" 103 - {{ $disabled }} 104 - {{ if $title }}title="{{ $title }}"{{ end }} 105 - class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 106 - hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 107 - hx-trigger="click" 108 - hx-swap="none" 109 - > 110 - {{ if $disabled }} 111 - {{ i "refresh-cw-off" "w-4 h-4" }} 112 - {{ else }} 113 - {{ i "refresh-cw" "w-4 h-4" }} 114 - {{ end }} 115 - <span>sync</span> 116 - </button> 117 - {{ end }} 118 - <a 119 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 120 - class="btn flex items-center gap-2 no-underline hover:no-underline" 121 - title="Compare branches or tags" 122 - > 123 - {{ i "git-compare" "w-4 h-4" }} 124 - </a> 97 + <!-- Clone dropdown in top right --> 98 + <div class="hidden md:flex items-center "> 99 + {{ template "repo/fragments/cloneDropdown" . }} 125 100 </div> 126 - </div> 101 + </div> 127 102 {{ end }} 128 103 129 104 {{ define "fileTree" }} ··· 131 106 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 107 133 108 {{ range .Files }} 134 - <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 - <div class="col-span-1"> 109 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 110 + <div class="col-span-2"> 136 111 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 112 {{ $icon := "folder" }} 138 113 {{ $iconStyle := "size-4 fill-current" }} ··· 144 119 {{ end }} 145 120 <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 121 <div class="flex items-center gap-2"> 147 - {{ i $icon $iconStyle }}{{ .Name }} 122 + {{ i $icon $iconStyle "flex-shrink-0" }} 123 + <span class="truncate">{{ .Name }}</span> 148 124 </div> 149 125 </a> 150 126 </div> 151 127 152 - <div class="text-xs col-span-1 text-right"> 128 + <div class="text-sm col-span-1 text-right"> 153 129 {{ with .LastCommit }} 154 130 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 131 {{ end }} ··· 210 186 </div> 211 187 212 188 <!-- commit info bar --> 213 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 189 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 214 190 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 215 191 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 216 192 {{ if $verified }} ··· 280 256 </a> 281 257 <div class="flex flex-col gap-1"> 282 258 {{ range .BranchesTrunc }} 283 - <div class="text-base flex items-center justify-between"> 284 - <div class="flex items-center gap-2"> 259 + <div class="text-base flex items-center justify-between overflow-hidden"> 260 + <div class="flex items-center gap-2 min-w-0 flex-1"> 285 261 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 286 - class="inline no-underline hover:underline dark:text-white"> 262 + class="inline-block truncate no-underline hover:underline dark:text-white"> 287 263 {{ .Reference.Name }} 288 264 </a> 289 265 {{ if .Commit }} 290 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 291 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 266 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 267 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 292 268 {{ end }} 293 269 {{ if .IsDefault }} 294 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 295 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 270 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 271 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 296 272 {{ end }} 297 273 </div> 298 274 {{ if ne $.Ref .Reference.Name }} 299 275 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 300 - class="text-xs flex gap-2 items-center" 276 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 301 277 title="Compare branches or tags"> 302 278 {{ i "git-compare" "w-3 h-3" }} compare 303 279 </a> 304 - {{end}} 280 + {{ end }} 305 281 </div> 306 282 {{ end }} 307 283 </div> ··· 347 323 348 324 {{ define "repoAfter" }} 349 325 {{- if or .HTMLReadme .Readme -}} 350 - <section 351 - class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 352 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 353 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 354 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 355 - {{ end }}" 356 - > 357 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 - {{- .Readme -}} 359 - </pre> 360 - {{- else -}} 361 - {{ .HTMLReadme }} 362 - {{- end -}}</article> 363 - </section> 326 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 327 + {{- if .ReadmeFileName -}} 328 + <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 329 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 330 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 331 + </div> 332 + {{- end -}} 333 + <section 334 + class="p-6 overflow-auto {{ if not .Raw }} 335 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 336 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 337 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 338 + {{ end }}" 339 + > 340 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 341 + {{- .Readme -}} 342 + </pre> 343 + {{- else -}} 344 + {{ .HTMLReadme }} 345 + {{- end -}}</article> 346 + </section> 347 + </div> 364 348 {{- end -}} 365 - 366 - {{ template "repo/fragments/cloneInstructions" . }} 367 349 {{ end }}
+1 -2
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 4 <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - {{ template "user/fragments/picHandleLink" $owner }} 5 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 7 6 8 7 <!-- show user "hats" --> 9 8 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+3 -3
appview/pages/templates/repo/issues/issue.html
··· 11 11 {{ define "repoContent" }} 12 12 <header class="pb-4"> 13 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 14 + {{ .Issue.Title | description }} 15 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 16 </h1> 17 17 </header> ··· 54 54 "Kind" $kind 55 55 "Count" (index $.Reactions $kind) 56 56 "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 57 + "ThreadAt" $.Issue.AtUri) 58 58 }} 59 59 {{ end }} 60 60 </div> ··· 70 70 {{ if gt $index 0 }} 71 71 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 72 {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 73 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 74 </div> 75 75 {{ end }} 76 76 </section>
+2 -3
appview/pages/templates/repo/issues/issues.html
··· 45 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 46 class="no-underline hover:underline" 47 47 > 48 - {{ .Title }} 48 + {{ .Title | description }} 49 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 50 </a> 51 51 </div> ··· 65 65 </span> 66 66 67 67 <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandleLink" $owner }} 68 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 70 69 </span> 71 70 72 71 <span class="before:content-['ยท']">
+1 -1
appview/pages/templates/repo/new.html
··· 63 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 64 {{ i "book-plus" "w-4 h-4" }} 65 65 create repo 66 - <span id="create-pull-spinner" class="group"> 66 + <span id="spinner" class="group"> 67 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 68 </span> 69 69 </button>
+2 -2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 23 23 </div> 24 24 {{ else if $allFail }} 25 25 <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-600" }} 26 + {{ i "x" "size-4 text-red-500" }} 27 27 <span>0/{{ $total }}</span> 28 28 </div> 29 29 {{ else if $allTimeout }} 30 30 <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-400" }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 32 <span>0/{{ $total }}</span> 33 33 </div> 34 34 {{ else }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 20 {{ else if eq $kind "timeout" }} 21 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 23 {{ else }} 24 24 {{ $icon = "x" }} 25 25 {{ $color = "text-red-600 dark:text-red-500" }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 19 20 20 {{ define "sidebar" }} 21 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 22 26 {{ with .Pipeline }} 23 27 {{ $id := .Id }} 24 28 <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 25 29 {{ range $name, $all := .Statuses }} 26 30 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 27 31 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 29 33 {{ $lastStatus := $all.Latest }} 30 34 {{ $kind := $lastStatus.Status.String }} 31 35
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 19 19 > 20 20 <option disabled selected>select a fork</option> 21 21 {{ range .Forks }} 22 - <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Name }} 22 + <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 + {{ .Did | resolve }}/{{ .Name }} 24 24 </option> 25 25 {{ end }} 26 26 </select>
+3 -3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 2 <header class="pb-4"> 3 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 4 + {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 6 </h1> 7 7 </header> ··· 28 28 </div> 29 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 30 opened by 31 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandleLink" $owner }} 31 + {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }} 33 32 <span class="select-none before:content-['\00B7']"></span> 34 33 {{ template "repo/fragments/time" .Pull.Created }} 35 34 ··· 45 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 46 45 {{ if .Pull.IsForkBased }} 47 46 {{ if .Pull.PullSource.Repo }} 47 + {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 48 <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 49 {{- else -}} 50 50 <span class="italic">[deleted fork]</span>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 52 </div> 53 53 {{ end }} 54 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 56 </div> 57 57 </div> 58 58 </a>
+2 -2
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 2 {{ $pull := index . 0 }} 3 3 {{ $pipeline := index . 1 }} 4 4 {{ with $pull }} ··· 9 9 </div> 10 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 12 + {{ .Title | description }} 13 13 </span> 14 14 </div> 15 15
+3 -3
appview/pages/templates/repo/pulls/interdiff.html
··· 30 30 31 31 {{ define "topbarLayout" }} 32 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/topbar" . }} 33 + {{ template "layouts/fragments/topbar" . }} 34 34 </header> 35 35 {{ end }} 36 36 ··· 55 55 56 56 {{ define "footerLayout" }} 57 57 <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 58 + {{ template "layouts/fragments/footer" . }} 59 59 </footer> 60 60 {{ end }} 61 61 ··· 68 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 70 </div> 71 - <div class="sticky top-0 flex-grow max-h-screen"> 71 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 72 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 73 </div> 74 74 {{end}}
+3 -3
appview/pages/templates/repo/pulls/patch.html
··· 36 36 37 37 {{ define "topbarLayout" }} 38 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/topbar" . }} 39 + {{ template "layouts/fragments/topbar" . }} 40 40 </header> 41 41 {{ end }} 42 42 ··· 61 61 62 62 {{ define "footerLayout" }} 63 63 <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 64 + {{ template "layouts/fragments/footer" . }} 65 65 </footer> 66 66 {{ end }} 67 67 ··· 73 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 75 </div> 76 - <div class="sticky top-0 flex-grow max-h-screen"> 76 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 77 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 78 </div> 79 79 {{end}}
+4 -5
appview/pages/templates/repo/pulls/pull.html
··· 47 47 <!-- round summary --> 48 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 49 <span class="gap-1 flex items-center"> 50 - {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 50 + {{ $owner := resolve $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by {{ template "user/fragments/picHandleLink" $owner }} 56 + by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 57 57 <span class="select-none before:content-['\00B7']"></span> 58 58 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 59 <span class="select-none before:content-['ยท']"></span> ··· 122 122 {{ end }} 123 123 </div> 124 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 125 + <span>{{ .Title | description }}</span> 126 126 {{ if gt (len .Body) 0 }} 127 127 <button 128 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 151 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 152 {{ end }} 153 153 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - {{ template "user/fragments/picHandleLink" $owner }} 154 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 156 155 <span class="before:content-['ยท']"></span> 157 156 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 157 </div>
+3 -4
appview/pages/templates/repo/pulls/pulls.html
··· 50 50 <div class="px-6 py-4 z-5"> 51 51 <div class="pb-2"> 52 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 53 + {{ .Title | description }} 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 57 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 59 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 59 {{ $icon := "ban" }} 61 60 ··· 76 75 </span> 77 76 78 77 <span class="ml-1"> 79 - {{ template "user/fragments/picHandleLink" $owner }} 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 80 79 </span> 81 80 82 81 <span class="before:content-['ยท']"> ··· 145 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 146 145 <div class="flex gap-2 items-center px-6"> 147 146 <div class="flex-grow min-w-0 w-full py-2"> 148 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 149 148 </div> 150 149 </div> 151 150 </a>
+3 -1
appview/pages/templates/repo/settings/general.html
··· 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 10 {{ template "deleteRepo" . }} 11 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 11 12 </div> 12 13 </section> 13 14 {{ end }} ··· 22 23 unless you specify a different branch. 23 24 </p> 24 25 </div> 25 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 27 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 27 28 <option value="" disabled selected > 28 29 Choose a default branch ··· 54 55 <button 55 56 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 57 type="button" 58 + hx-swap="none" 57 59 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 60 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 61 {{ i "trash-2" "size-4" }}
+9 -4
appview/pages/templates/repo/settings/pipelines.html
··· 34 34 {{ else }} 35 35 <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 36 <select 37 - id="spindle" 37 + id="spindle" 38 38 name="spindle" 39 - required 39 + required 40 40 class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 - <option value="" disabled> 41 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 42 44 Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 43 48 </option> 44 49 {{ range $.Spindles }} 45 50 <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> ··· 82 87 {{ end }} 83 88 84 89 {{ define "addSecretButton" }} 85 - <button 90 + <button 86 91 class="btn flex items-center gap-2" 87 92 popovertarget="add-secret-modal" 88 93 popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+5 -5
appview/pages/templates/repo/tree.html
··· 54 54 55 55 {{ range .Files }} 56 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 - <div class="col-span-6 md:col-span-3"> 57 + <div class="col-span-8 md:col-span-4"> 58 58 {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 62 62 {{ if .IsFile }} 63 63 {{ $icon = "file" }} 64 - {{ $iconStyle = "flex-shrink-0 size-4" }} 64 + {{ $iconStyle = "size-4" }} 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }} 68 + {{ i $icon $iconStyle "flex-shrink-0" }} 69 69 <span class="truncate">{{ .Name }}</span> 70 70 </div> 71 71 </a> 72 72 </div> 73 73 74 - <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 74 + <div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden"> 75 75 {{ with .LastCommit }} 76 76 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 77 77 {{ end }} 78 78 </div> 79 79 80 - <div class="col-span-6 md:col-span-2 text-right"> 80 + <div class="col-span-4 md:col-span-2 text-sm text-right"> 81 81 {{ with .LastCommit }} 82 82 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 83 {{ end }}
-192
appview/pages/templates/settings.html
··· 1 - {{ define "title" }}settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="flex flex-col"> 8 - {{ block "profile" . }} {{ end }} 9 - {{ block "keys" . }} {{ end }} 10 - {{ block "emails" . }} {{ end }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - {{ if .LoggedInUser.Handle }} 19 - <dt class="font-bold">handle</dt> 20 - <dd>@{{ .LoggedInUser.Handle }}</dd> 21 - {{ end }} 22 - <dt class="font-bold">did</dt> 23 - <dd>{{ .LoggedInUser.Did }}</dd> 24 - <dt class="font-bold">pds</dt> 25 - <dd>{{ .LoggedInUser.Pds }}</dd> 26 - </dl> 27 - </section> 28 - {{ end }} 29 - 30 - {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 - <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range $index, $key := .PubKeys }} 36 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 - <div class="flex flex-col gap-1"> 38 - <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 - <p class="font-bold dark:text-white">{{ .Name }}</p> 41 - </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 - <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 - </div> 46 - </div> 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete key" 50 - hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 - hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 - > 53 - {{ i "trash-2" "w-5 h-5" }} 54 - <span class="hidden md:inline">delete</span> 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - </button> 57 - </div> 58 - {{ end }} 59 - </div> 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#add-sshkey-spinner" 63 - hx-swap="none" 64 - class="max-w-2xl mb-8 space-y-4" 65 - > 66 - <input 67 - type="text" 68 - id="name" 69 - name="name" 70 - placeholder="key name" 71 - required 72 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 - 74 - <input 75 - id="key" 76 - name="key" 77 - placeholder="ssh-rsa AAAAAA..." 78 - required 79 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 - 81 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 - <span>add key</span> 83 - <span id="add-sshkey-spinner" class="group"> 84 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 - </span> 86 - </button> 87 - 88 - <div id="settings-keys" class="error dark:text-red-400"></div> 89 - </form> 90 - </section> 91 - {{ end }} 92 - 93 - {{ define "emails" }} 94 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 - <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 - <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 - {{ range $index, $email := .Emails }} 99 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 - <div class="flex flex-col gap-2"> 101 - <div class="inline-flex items-center gap-4"> 102 - {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 - <p class="font-bold dark:text-white">{{ .Address }}</p> 104 - <div class="inline-flex items-center gap-1"> 105 - {{ if .Verified }} 106 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 107 - {{ else }} 108 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 109 - {{ end }} 110 - {{ if .Primary }} 111 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 112 - {{ end }} 113 - </div> 114 - </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 - </div> 117 - <div class="flex gap-2 items-center"> 118 - {{ if not .Verified }} 119 - <button 120 - class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 - hx-post="/settings/emails/verify/resend" 122 - hx-swap="none" 123 - href="#" 124 - hx-vals='{"email": "{{ .Address }}"}'> 125 - {{ i "rotate-cw" "w-5 h-5" }} 126 - <span class="hidden md:inline">resend</span> 127 - </button> 128 - {{ end }} 129 - {{ if and (not .Primary) .Verified }} 130 - <a 131 - class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 - hx-post="/settings/emails/primary" 133 - hx-swap="none" 134 - href="#" 135 - hx-vals='{"email": "{{ .Address }}"}'> 136 - set as primary 137 - </a> 138 - {{ end }} 139 - {{ if not .Primary }} 140 - <form 141 - hx-delete="/settings/emails" 142 - hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 - hx-indicator="#delete-email-{{ $index }}-spinner" 144 - > 145 - <input type="hidden" name="email" value="{{ .Address }}"> 146 - <button 147 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 - title="Delete email" 149 - type="submit" 150 - > 151 - {{ i "trash-2" "w-5 h-5" }} 152 - <span class="hidden md:inline">delete</span> 153 - <span id="delete-email-{{ $index }}-spinner" class="group"> 154 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 - </span> 156 - </button> 157 - </form> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }} 162 - </div> 163 - <form 164 - hx-put="/settings/emails" 165 - hx-swap="none" 166 - class="max-w-2xl mb-8 space-y-4" 167 - hx-indicator="#add-email-spinner" 168 - > 169 - <input 170 - type="email" 171 - id="email" 172 - name="email" 173 - placeholder="your@email.com" 174 - required 175 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 - > 177 - 178 - <button 179 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 - type="submit" 181 - > 182 - <span>add email</span> 183 - <span id="add-email-spinner" class="group"> 184 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 - </span> 186 - </button> 187 - 188 - <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 - <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 - </form> 191 - </section> 192 - {{ end }}
+2 -4
appview/pages/templates/spindles/dashboard.html
··· 42 42 <div> 43 43 <div class="flex justify-between items-center"> 44 44 <div class="flex items-center gap-2"> 45 - {{ i "user" "size-4" }} 46 - {{ $user := index $.DidHandleMap . }} 47 - <a href="/{{ $user }}">{{ $user }}</a> 45 + {{ template "user/fragments/picHandleLink" . }} 48 46 </div> 49 47 {{ if ne $.LoggedInUser.Did . }} 50 48 {{ block "removeMemberButton" (list $ . ) }} {{ end }} ··· 109 107 hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 110 108 hx-swap="none" 111 109 hx-vals='{"member": "{{$member}}" }' 112 - hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?" 110 + hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 113 111 > 114 112 {{ i "user-minus" "w-4 h-4" }} 115 113 remove
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addMemberPopover" . }} {{ end }} 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 20 20 21 - {{ define "addMemberPopover" }} 21 + {{ define "addSpindleMemberPopover" }} 22 22 <form 23 23 hx-post="/spindles/{{ .Instance }}/add" 24 24 hx-indicator="#spinner"
+11 -9
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 1 {{ define "spindles/fragments/spindleListing" }} 2 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "leftSide" . }} {{ end }} 4 - {{ block "rightSide" . }} {{ end }} 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 5 </div> 6 6 {{ end }} 7 7 8 - {{ define "leftSide" }} 8 + {{ define "spindleLeftSide" }} 9 9 {{ if .Verified }} 10 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 - {{ .Instance }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 13 15 <span class="text-gray-500"> 14 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 17 </span> ··· 25 27 {{ end }} 26 28 {{ end }} 27 29 28 - {{ define "rightSide" }} 30 + {{ define "spindleRightSide" }} 29 31 <div id="right-side" class="flex gap-2"> 30 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 31 33 {{ if .Verified }} ··· 33 35 {{ template "spindles/fragments/addMemberModal" . }} 34 36 {{ else }} 35 37 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 36 - {{ block "retryButton" . }} {{ end }} 38 + {{ block "spindleRetryButton" . }} {{ end }} 37 39 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 40 + {{ block "spindleDeleteButton" . }} {{ end }} 39 41 </div> 40 42 {{ end }} 41 43 42 - {{ define "deleteButton" }} 44 + {{ define "spindleDeleteButton" }} 43 45 <button 44 46 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 47 title="Delete spindle" ··· 55 57 {{ end }} 56 58 57 59 58 - {{ define "retryButton" }} 60 + {{ define "spindleRetryButton" }} 59 61 <button 60 62 class="btn gap-2 group" 61 63 title="Retry spindle verification"
+3 -2
appview/pages/templates/strings/fragments/form.html
··· 13 13 type="text" 14 14 id="filename" 15 15 name="filename" 16 - placeholder="Filename with extension" 16 + placeholder="Filename" 17 17 required 18 18 value="{{ .String.Filename }}" 19 19 class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" ··· 31 31 name="content" 32 32 id="content-textarea" 33 33 wrap="off" 34 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 35 rows="20" 36 + spellcheck="false" 36 37 placeholder="Paste your string here!" 37 38 required>{{ .String.Contents }}</textarea> 38 39 <div class="flex justify-between items-center">
-4
appview/pages/templates/strings/put.html
··· 1 1 {{ define "title" }}publish a new string{{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 <div class="px-6 py-2 mb-4"> 9 5 {{ if eq .Action "new" }}
+15 -16
appview/pages/templates/strings/string.html
··· 8 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 9 {{ end }} 10 10 11 - {{ define "topbar" }} 12 - {{ template "layouts/topbar" $ }} 13 - {{ end }} 14 - 15 11 {{ define "content" }} 16 12 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> ··· 19 15 <div> 20 16 <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 17 <span class="select-none">/</span> 22 - <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 18 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 19 </div> 24 20 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 21 <div class="flex gap-2 text-base"> ··· 35 31 title="Delete string" 36 32 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 33 hx-swap="none" 38 - hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 34 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 35 > 40 36 {{ i "trash-2" "size-4" }} 41 37 <span class="hidden md:inline">delete</span> ··· 44 40 </div> 45 41 {{ end }} 46 42 </div> 47 - <span class="flex items-center"> 43 + <span> 48 44 {{ with .String.Description }} 49 45 {{ . }} 50 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 - {{ end }} 52 - 53 - {{ with .String.Edited }} 54 - <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 - {{ else }} 56 - {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 46 {{ end }} 58 47 </span> 59 48 </section> 60 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 50 <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 62 - <span>{{ .String.Filename }}</span> 51 + <span> 52 + {{ .String.Filename }} 53 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 54 + <span> 55 + {{ with .String.Edited }} 56 + edited {{ template "repo/fragments/shortTimeAgo" . }} 57 + {{ else }} 58 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 59 + {{ end }} 60 + </span> 61 + </span> 63 62 <div> 64 63 <span>{{ .Stats.LineCount }} lines</span> 65 64 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> ··· 74 73 {{ end }} 75 74 </div> 76 75 </div> 77 - <div class="overflow-auto relative"> 76 + <div class="overflow-x-auto overflow-y-hidden relative"> 78 77 {{ if .ShowRendered }} 79 78 <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 79 {{ else }}
+61
appview/pages/templates/strings/timeline.html
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "content" }} 4 + {{ block "timeline" $ }}{{ end }} 5 + {{ end }} 6 + 7 + {{ define "timeline" }} 8 + <div> 9 + <div class="p-6"> 10 + <p class="text-xl font-bold dark:text-white">All strings</p> 11 + </div> 12 + 13 + <div class="flex flex-col gap-4"> 14 + {{ range $i, $s := .Strings }} 15 + <div class="relative"> 16 + {{ if ne $i 0 }} 17 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 18 + {{ end }} 19 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 20 + {{ template "stringCard" $s }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "stringCard" }} 29 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 + <div class="font-medium dark:text-white flex gap-2 items-center"> 31 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 32 + </div> 33 + {{ with .Description }} 34 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 35 + {{ . }} 36 + </div> 37 + {{ end }} 38 + 39 + {{ template "stringCardInfo" . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "stringCardInfo" }} 44 + {{ $stat := .Stats }} 45 + {{ $resolved := resolve .Did.String }} 46 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 + {{ template "user/fragments/picHandle" $resolved }} 49 + </a> 50 + <span class="select-none [&:before]:content-['ยท']"></span> 51 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 + <span class="select-none [&:before]:content-['ยท']"></span> 53 + {{ with .Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .Created }} 57 + {{ end }} 58 + </div> 59 + {{ end }} 60 + 61 +
+183
appview/pages/templates/timeline/timeline.html
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 + <span class="select-none after:content-['ยท']"></span> 178 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 179 + </div> 180 + {{ end }} 181 + </div> 182 + </div> 183 + {{ end }}
-161
appview/pages/templates/timeline.html
··· 1 - {{ define "title" }}timeline{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline ยท tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 8 - {{ end }} 9 - 10 - {{ define "topbar" }} 11 - {{ template "layouts/topbar" $ }} 12 - {{ end }} 13 - 14 - {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 21 - {{ end }} 22 - 23 - {{ define "hero" }} 24 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 26 - 27 - <p class="text-lg"> 28 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 29 - </p> 30 - <p class="text-lg"> 31 - we envision a place where developers have complete ownership of their 32 - code, open source communities can freely self-govern and most 33 - importantly, coding can be social and fun again. 34 - </p> 35 - 36 - <div class="flex gap-6 items-center"> 37 - <a href="/signup" class="no-underline hover:no-underline "> 38 - <button class="btn-create flex gap-2 px-4 items-center"> 39 - join now {{ i "arrow-right" "size-4" }} 40 - </button> 41 - </a> 42 - </div> 43 - </div> 44 - {{ end }} 45 - 46 - {{ define "timeline" }} 47 - <div> 48 - <div class="p-6"> 49 - <p class="text-xl font-bold dark:text-white">Timeline</p> 50 - </div> 51 - 52 - <div class="flex flex-col gap-4"> 53 - {{ range $i, $e := .Timeline }} 54 - <div class="relative"> 55 - {{ if ne $i 0 }} 56 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 57 - {{ end }} 58 - {{ with $e }} 59 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 60 - {{ if .Repo }} 61 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 62 - {{ else if .Star }} 63 - {{ block "starEvent" (list $ .Star) }} {{ end }} 64 - {{ else if .Follow }} 65 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 66 - {{ end }} 67 - </div> 68 - {{ end }} 69 - </div> 70 - {{ end }} 71 - </div> 72 - </div> 73 - {{ end }} 74 - 75 - {{ define "repoEvent" }} 76 - {{ $root := index . 0 }} 77 - {{ $repo := index . 1 }} 78 - {{ $source := index . 2 }} 79 - {{ $userHandle := index $root.DidHandleMap $repo.Did }} 80 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 81 - {{ template "user/fragments/picHandleLink" $userHandle }} 82 - {{ with $source }} 83 - forked 84 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline"> 85 - {{ index $root.DidHandleMap .Did }}/{{ .Name }} 86 - </a> 87 - to 88 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 89 - {{ else }} 90 - created 91 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 92 - {{ $repo.Name }} 93 - </a> 94 - {{ end }} 95 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 96 - </div> 97 - {{ with $repo }} 98 - {{ template "user/fragments/repoCard" (list $root . true) }} 99 - {{ end }} 100 - {{ end }} 101 - 102 - {{ define "starEvent" }} 103 - {{ $root := index . 0 }} 104 - {{ $star := index . 1 }} 105 - {{ with $star }} 106 - {{ $starrerHandle := index $root.DidHandleMap .StarredByDid }} 107 - {{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }} 108 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 109 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 110 - starred 111 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 112 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 113 - </a> 114 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 115 - </div> 116 - {{ with .Repo }} 117 - {{ template "user/fragments/repoCard" (list $root . true) }} 118 - {{ end }} 119 - {{ end }} 120 - {{ end }} 121 - 122 - 123 - {{ define "followEvent" }} 124 - {{ $root := index . 0 }} 125 - {{ $follow := index . 1 }} 126 - {{ $profile := index . 2 }} 127 - {{ $stat := index . 3 }} 128 - 129 - {{ $userHandle := index $root.DidHandleMap $follow.UserDid }} 130 - {{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }} 131 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 132 - {{ template "user/fragments/picHandleLink" $userHandle }} 133 - followed 134 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 135 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 136 - </div> 137 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 138 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 139 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 140 - </div> 141 - 142 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 143 - <a href="/{{ $subjectHandle }}"> 144 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 145 - </a> 146 - {{ with $profile }} 147 - {{ with .Description }} 148 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 149 - {{ end }} 150 - {{ end }} 151 - {{ with $stat }} 152 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 153 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 154 - <span id="followers">{{ .Followers }} followers</span> 155 - <span class="select-none after:content-['ยท']"></span> 156 - <span id="following">{{ .Following }} following</span> 157 - </div> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }}
+18
appview/pages/templates/user/followers.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "followers" }} 10 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 + {{ range .Followers }} 13 + {{ template "user/fragments/followCard" . }} 14 + {{ else }} 15 + <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 + {{ end }} 17 + </div> 18 + {{ end }}
+18
appview/pages/templates/user/following.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "following" }} 10 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 + {{ range .Following }} 13 + {{ template "user/fragments/followCard" . }} 14 + {{ else }} 15 + <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 + {{ end }} 17 + </div> 18 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 27 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 29 29 <div class="flex justify-between items-center w-full"> 30 - <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 31 <div class="flex gap-1 items-center"> 32 32 {{ i "star" "size-4 fill-current" }} 33 33 <span>{{ .RepoStats.StarCount }}</span>
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 - <button id="followBtn" 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 3 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 9 {{ end }} 10 10 11 11 hx-trigger="click" 12 - hx-target="#followBtn" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
··· 1 + {{ define "user/fragments/followCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 + </div> 8 + 9 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 + <a href="/{{ $userIdent }}"> 11 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + </a> 13 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 + <span class="select-none after:content-['ยท']"></span> 18 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 + </div> 20 + </div> 21 + 22 + {{ if ne .FollowStatus.String "IsSelf" }} 23 + <div class="max-w-24"> 24 + {{ template "user/fragments/follow" . }} 25 + </div> 26 + {{ end }} 27 + </div> 28 + </div> 29 + {{ end }}
+3 -2
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - <a href="/{{ . }}" class="flex items-center"> 3 - {{ template "user/fragments/picHandle" . }} 2 + {{ $resolved := resolve . }} 3 + <a href="/{{ $resolved }}" class="flex items-center"> 4 + {{ template "user/fragments/picHandle" $resolved }} 4 5 </a> 5 6 {{ end }}
+21 -17
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 5 <div class="w-3/4 aspect-square relative"> ··· 7 7 </div> 8 8 </div> 9 9 <div class="col-span-2"> 10 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 12 - {{ didOrHandle .UserDid .UserHandle }} 13 - </p> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ $userIdent }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ $userIdent }} 14 + </p> 15 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 14 17 15 18 <div class="md:hidden"> 16 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 19 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 17 20 </div> 18 21 </div> 19 22 <div class="col-span-3 md:col-span-full"> ··· 26 29 {{ end }} 27 30 28 31 <div class="hidden md:block"> 29 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 32 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 30 33 </div> 31 34 32 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 39 42 {{ if .IncludeBluesky }} 40 43 <div class="flex items-center gap-2"> 41 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 42 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 45 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 43 46 </div> 44 47 {{ end }} 45 48 {{ range $link := .Links }} ··· 81 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 82 85 </div> 83 86 </div> 84 - </div> 85 87 {{ end }} 86 88 87 89 {{ define "followerFollowing" }} 88 - {{ $followers := index . 0 }} 89 - {{ $following := index . 1 }} 90 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 91 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 92 - <span id="followers">{{ $followers }} followers</span> 93 - <span class="select-none after:content-['ยท']"></span> 94 - <span id="following">{{ $following }} following</span> 95 - </div> 90 + {{ $root := index . 0 }} 91 + {{ $userIdent := index . 1 }} 92 + {{ with $root }} 93 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 + <span class="select-none after:content-['ยท']"></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 + </div> 99 + {{ end }} 96 100 {{ end }} 97 101
+40 -34
appview/pages/templates/user/fragments/repoCard.html
··· 4 4 {{ $fullName := index . 2 }} 5 5 6 6 {{ with $repo }} 7 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 8 - <div class="font-medium dark:text-white flex gap-2 items-center"> 9 - {{- if $fullName -}} 10 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a> 11 - {{- else -}} 12 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a> 13 - {{- end -}} 7 + <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 8 + <div class="font-medium dark:text-white flex items-center"> 9 + {{ if .Source }} 10 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 11 + {{ else }} 12 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 13 + {{ end }} 14 + 15 + {{ $repoOwner := resolve .Did }} 16 + {{- if $fullName -}} 17 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 + {{- else -}} 19 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 + {{- end -}} 21 + </div> 22 + {{ with .Description }} 23 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 24 + {{ . | description }} 14 25 </div> 15 - {{ with .Description }} 16 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 17 - {{ . }} 18 - </div> 19 - {{ end }} 26 + {{ end }} 20 27 21 - {{ if .RepoStats }} 22 - {{ block "repoStats" .RepoStats }} {{ end }} 23 - {{ end }} 28 + {{ if .RepoStats }} 29 + {{ block "repoStats" .RepoStats }}{{ end }} 30 + {{ end }} 24 31 </div> 25 32 {{ end }} 26 33 {{ end }} 27 34 28 35 {{ define "repoStats" }} 29 - <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 36 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 30 37 {{ with .Language }} 31 - <div class="flex gap-2 items-center text-sm"> 32 - <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 - <span>{{ . }}</span> 34 - </div> 38 + <div class="flex gap-2 items-center text-sm"> 39 + <div class="size-2 rounded-full" 40 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 41 + <span>{{ . }}</span> 42 + </div> 35 43 {{ end }} 36 44 {{ with .StarCount }} 37 - <div class="flex gap-1 items-center text-sm"> 38 - {{ i "star" "w-3 h-3 fill-current" }} 39 - <span>{{ . }}</span> 40 - </div> 45 + <div class="flex gap-1 items-center text-sm"> 46 + {{ i "star" "w-3 h-3 fill-current" }} 47 + <span>{{ . }}</span> 48 + </div> 41 49 {{ end }} 42 50 {{ with .IssueCount.Open }} 43 - <div class="flex gap-1 items-center text-sm"> 44 - {{ i "circle-dot" "w-3 h-3" }} 45 - <span>{{ . }}</span> 46 - </div> 51 + <div class="flex gap-1 items-center text-sm"> 52 + {{ i "circle-dot" "w-3 h-3" }} 53 + <span>{{ . }}</span> 54 + </div> 47 55 {{ end }} 48 56 {{ with .PullCount.Open }} 49 - <div class="flex gap-1 items-center text-sm"> 50 - {{ i "git-pull-request" "w-3 h-3" }} 51 - <span>{{ . }}</span> 52 - </div> 57 + <div class="flex gap-1 items-center text-sm"> 58 + {{ i "git-pull-request" "w-3 h-3" }} 59 + <span>{{ . }}</span> 60 + </div> 53 61 {{ end }} 54 62 </div> 55 63 {{ end }} 56 - 57 -
+1
appview/pages/templates/user/login.html
··· 41 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 42 </span> 43 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 44 45 45 46 <button 46 47 class="btn w-full my-2 mt-6 text-base "
+258
appview/pages/templates/user/overview.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 5 + <div class="grid grid-cols-1 gap-4"> 6 + {{ block "ownRepos" . }}{{ end }} 7 + {{ block "collaboratingRepos" . }}{{ end }} 8 + </div> 9 + </div> 10 + <div class="md:col-span-4 order-3 md:order-3"> 11 + {{ block "profileTimeline" . }}{{ end }} 12 + </div> 13 + {{ end }} 14 + 15 + {{ define "profileTimeline" }} 16 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 + <div class="flex flex-col gap-4 relative"> 18 + {{ if .ProfileTimeline.IsEmpty }} 19 + <p class="dark:text-white">This user does not have any activity yet.</p> 20 + {{ end }} 21 + 22 + {{ with .ProfileTimeline }} 23 + {{ range $idx, $byMonth := .ByMonth }} 24 + {{ with $byMonth }} 25 + {{ if not .IsEmpty }} 26 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6"> 27 + <p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400"> 28 + {{ if eq $idx 0 }} 29 + this month 30 + {{ else }} 31 + {{$idx}} month{{if ne $idx 1}}s{{end}} ago 32 + {{ end }} 33 + </p> 34 + 35 + <div class="flex flex-col gap-1"> 36 + {{ block "repoEvents" .RepoEvents }} {{ end }} 37 + {{ block "issueEvents" .IssueEvents }} {{ end }} 38 + {{ block "pullEvents" .PullEvents }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }} 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "repoEvents" }} 49 + {{ if gt (len .) 0 }} 50 + <details> 51 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 52 + <div class="flex flex-wrap items-center gap-2"> 53 + {{ i "book-plus" "w-4 h-4" }} 54 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 55 + </div> 56 + </summary> 57 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 58 + {{ range . }} 59 + <div class="flex flex-wrap items-center gap-2"> 60 + <span class="text-gray-500 dark:text-gray-400"> 61 + {{ if .Source }} 62 + {{ i "git-fork" "w-4 h-4" }} 63 + {{ else }} 64 + {{ i "book-plus" "w-4 h-4" }} 65 + {{ end }} 66 + </span> 67 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 68 + {{- .Repo.Name -}} 69 + </a> 70 + </div> 71 + {{ end }} 72 + </div> 73 + </details> 74 + {{ end }} 75 + {{ end }} 76 + 77 + {{ define "issueEvents" }} 78 + {{ $items := .Items }} 79 + {{ $stats := .Stats }} 80 + 81 + {{ if gt (len $items) 0 }} 82 + <details> 83 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 84 + <div class="flex flex-wrap items-center gap-2"> 85 + {{ i "circle-dot" "w-4 h-4" }} 86 + 87 + <div> 88 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 89 + </div> 90 + 91 + {{ if gt $stats.Open 0 }} 92 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 93 + {{$stats.Open}} open 94 + </span> 95 + {{ end }} 96 + 97 + {{ if gt $stats.Closed 0 }} 98 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 99 + {{$stats.Closed}} closed 100 + </span> 101 + {{ end }} 102 + 103 + </div> 104 + </summary> 105 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 106 + {{ range $items }} 107 + {{ $repoOwner := resolve .Metadata.Repo.Did }} 108 + {{ $repoName := .Metadata.Repo.Name }} 109 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 110 + 111 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 112 + {{ if .Open }} 113 + <span class="text-green-600 dark:text-green-500"> 114 + {{ i "circle-dot" "w-4 h-4" }} 115 + </span> 116 + {{ else }} 117 + <span class="text-gray-500 dark:text-gray-400"> 118 + {{ i "ban" "w-4 h-4" }} 119 + </span> 120 + {{ end }} 121 + <div class="flex-none min-w-8 text-right"> 122 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 123 + </div> 124 + <div class="break-words max-w-full"> 125 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 126 + {{ .Title -}} 127 + </a> 128 + on 129 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 130 + {{$repoUrl}} 131 + </a> 132 + </div> 133 + </div> 134 + {{ end }} 135 + </div> 136 + </details> 137 + {{ end }} 138 + {{ end }} 139 + 140 + {{ define "pullEvents" }} 141 + {{ $items := .Items }} 142 + {{ $stats := .Stats }} 143 + {{ if gt (len $items) 0 }} 144 + <details> 145 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 146 + <div class="flex flex-wrap items-center gap-2"> 147 + {{ i "git-pull-request" "w-4 h-4" }} 148 + 149 + <div> 150 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 151 + </div> 152 + 153 + {{ if gt $stats.Open 0 }} 154 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 155 + {{$stats.Open}} open 156 + </span> 157 + {{ end }} 158 + 159 + {{ if gt $stats.Merged 0 }} 160 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 161 + {{$stats.Merged}} merged 162 + </span> 163 + {{ end }} 164 + 165 + 166 + {{ if gt $stats.Closed 0 }} 167 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 168 + {{$stats.Closed}} closed 169 + </span> 170 + {{ end }} 171 + 172 + </div> 173 + </summary> 174 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 175 + {{ range $items }} 176 + {{ $repoOwner := resolve .Repo.Did }} 177 + {{ $repoName := .Repo.Name }} 178 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 179 + 180 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 181 + {{ if .State.IsOpen }} 182 + <span class="text-green-600 dark:text-green-500"> 183 + {{ i "git-pull-request" "w-4 h-4" }} 184 + </span> 185 + {{ else if .State.IsMerged }} 186 + <span class="text-purple-600 dark:text-purple-500"> 187 + {{ i "git-merge" "w-4 h-4" }} 188 + </span> 189 + {{ else }} 190 + <span class="text-gray-600 dark:text-gray-300"> 191 + {{ i "git-pull-request-closed" "w-4 h-4" }} 192 + </span> 193 + {{ end }} 194 + <div class="flex-none min-w-8 text-right"> 195 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 196 + </div> 197 + <div class="break-words max-w-full"> 198 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 199 + {{ .Title -}} 200 + </a> 201 + on 202 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 203 + {{$repoUrl}} 204 + </a> 205 + </div> 206 + </div> 207 + {{ end }} 208 + </div> 209 + </details> 210 + {{ end }} 211 + {{ end }} 212 + 213 + {{ define "ownRepos" }} 214 + <div> 215 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 216 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 217 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 218 + <span>PINNED REPOS</span> 219 + </a> 220 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 221 + <button 222 + hx-get="profile/edit-pins" 223 + hx-target="#all-repos" 224 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 225 + {{ i "pencil" "w-3 h-3" }} 226 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 227 + </button> 228 + {{ end }} 229 + </div> 230 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 231 + {{ range .Repos }} 232 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 233 + {{ template "user/fragments/repoCard" (list $ . false) }} 234 + </div> 235 + {{ else }} 236 + <p class="dark:text-white">This user does not have any pinned repos.</p> 237 + {{ end }} 238 + </div> 239 + </div> 240 + {{ end }} 241 + 242 + {{ define "collaboratingRepos" }} 243 + {{ if gt (len .CollaboratingRepos) 0 }} 244 + <div> 245 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 246 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 247 + {{ range .CollaboratingRepos }} 248 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 249 + {{ template "user/fragments/repoCard" (list $ . true) }} 250 + </div> 251 + {{ else }} 252 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 253 + {{ end }} 254 + </div> 255 + </div> 256 + {{ end }} 257 + {{ end }} 258 +
-325
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - <div class="grid grid-cols-1 gap-4"> 14 - {{ template "user/fragments/profileCard" .Card }} 15 - {{ block "punchcard" .Punchcard }} {{ end }} 16 - </div> 17 - </div> 18 - <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 - <div class="grid grid-cols-1 gap-4"> 20 - {{ block "ownRepos" . }}{{ end }} 21 - {{ block "collaboratingRepos" . }}{{ end }} 22 - </div> 23 - </div> 24 - <div class="md:col-span-4 order-3 md:order-3"> 25 - {{ block "profileTimeline" . }}{{ end }} 26 - </div> 27 - </div> 28 - {{ end }} 29 - 30 - {{ define "profileTimeline" }} 31 - <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 32 - <div class="flex flex-col gap-4 relative"> 33 - {{ with .ProfileTimeline }} 34 - {{ range $idx, $byMonth := .ByMonth }} 35 - {{ with $byMonth }} 36 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 - {{ if eq $idx 0 }} 38 - 39 - {{ else }} 40 - {{ $s := "s" }} 41 - {{ if eq $idx 1 }} 42 - {{ $s = "" }} 43 - {{ end }} 44 - <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 - {{ end }} 46 - 47 - {{ if .IsEmpty }} 48 - <div class="text-gray-500 dark:text-gray-400"> 49 - No activity for this month 50 - </div> 51 - {{ else }} 52 - <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }} 54 - {{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }} 55 - {{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - 60 - {{ end }} 61 - {{ else }} 62 - <p class="dark:text-white">This user does not have any activity yet.</p> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 - 68 - {{ define "repoEvents" }} 69 - {{ $items := index . 0 }} 70 - {{ $handleMap := index . 1 }} 71 - 72 - {{ if gt (len $items) 0 }} 73 - <details> 74 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 - <div class="flex flex-wrap items-center gap-2"> 76 - {{ i "book-plus" "w-4 h-4" }} 77 - created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 78 - </div> 79 - </summary> 80 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 81 - {{ range $items }} 82 - <div class="flex flex-wrap items-center gap-2"> 83 - <span class="text-gray-500 dark:text-gray-400"> 84 - {{ if .Source }} 85 - {{ i "git-fork" "w-4 h-4" }} 86 - {{ else }} 87 - {{ i "book-plus" "w-4 h-4" }} 88 - {{ end }} 89 - </span> 90 - <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 91 - {{- .Repo.Name -}} 92 - </a> 93 - </div> 94 - {{ end }} 95 - </div> 96 - </details> 97 - {{ end }} 98 - {{ end }} 99 - 100 - {{ define "issueEvents" }} 101 - {{ $i := index . 0 }} 102 - {{ $items := $i.Items }} 103 - {{ $stats := $i.Stats }} 104 - {{ $handleMap := index . 1 }} 105 - 106 - {{ if gt (len $items) 0 }} 107 - <details> 108 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 109 - <div class="flex flex-wrap items-center gap-2"> 110 - {{ i "circle-dot" "w-4 h-4" }} 111 - 112 - <div> 113 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 114 - </div> 115 - 116 - {{ if gt $stats.Open 0 }} 117 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 118 - {{$stats.Open}} open 119 - </span> 120 - {{ end }} 121 - 122 - {{ if gt $stats.Closed 0 }} 123 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 124 - {{$stats.Closed}} closed 125 - </span> 126 - {{ end }} 127 - 128 - </div> 129 - </summary> 130 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 131 - {{ range $items }} 132 - {{ $repoOwner := index $handleMap .Metadata.Repo.Did }} 133 - {{ $repoName := .Metadata.Repo.Name }} 134 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 135 - 136 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 137 - {{ if .Open }} 138 - <span class="text-green-600 dark:text-green-500"> 139 - {{ i "circle-dot" "w-4 h-4" }} 140 - </span> 141 - {{ else }} 142 - <span class="text-gray-500 dark:text-gray-400"> 143 - {{ i "ban" "w-4 h-4" }} 144 - </span> 145 - {{ end }} 146 - <div class="flex-none min-w-8 text-right"> 147 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 148 - </div> 149 - <div class="break-words max-w-full"> 150 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 151 - {{ .Title -}} 152 - </a> 153 - on 154 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 155 - {{$repoUrl}} 156 - </a> 157 - </div> 158 - </div> 159 - {{ end }} 160 - </div> 161 - </details> 162 - {{ end }} 163 - {{ end }} 164 - 165 - {{ define "pullEvents" }} 166 - {{ $i := index . 0 }} 167 - {{ $items := $i.Items }} 168 - {{ $stats := $i.Stats }} 169 - {{ $handleMap := index . 1 }} 170 - {{ if gt (len $items) 0 }} 171 - <details> 172 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 173 - <div class="flex flex-wrap items-center gap-2"> 174 - {{ i "git-pull-request" "w-4 h-4" }} 175 - 176 - <div> 177 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 178 - </div> 179 - 180 - {{ if gt $stats.Open 0 }} 181 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 182 - {{$stats.Open}} open 183 - </span> 184 - {{ end }} 185 - 186 - {{ if gt $stats.Merged 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 188 - {{$stats.Merged}} merged 189 - </span> 190 - {{ end }} 191 - 192 - 193 - {{ if gt $stats.Closed 0 }} 194 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 195 - {{$stats.Closed}} closed 196 - </span> 197 - {{ end }} 198 - 199 - </div> 200 - </summary> 201 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 202 - {{ range $items }} 203 - {{ $repoOwner := index $handleMap .Repo.Did }} 204 - {{ $repoName := .Repo.Name }} 205 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 206 - 207 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 208 - {{ if .State.IsOpen }} 209 - <span class="text-green-600 dark:text-green-500"> 210 - {{ i "git-pull-request" "w-4 h-4" }} 211 - </span> 212 - {{ else if .State.IsMerged }} 213 - <span class="text-purple-600 dark:text-purple-500"> 214 - {{ i "git-merge" "w-4 h-4" }} 215 - </span> 216 - {{ else }} 217 - <span class="text-gray-600 dark:text-gray-300"> 218 - {{ i "git-pull-request-closed" "w-4 h-4" }} 219 - </span> 220 - {{ end }} 221 - <div class="flex-none min-w-8 text-right"> 222 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 223 - </div> 224 - <div class="break-words max-w-full"> 225 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 226 - {{ .Title -}} 227 - </a> 228 - on 229 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 230 - {{$repoUrl}} 231 - </a> 232 - </div> 233 - </div> 234 - {{ end }} 235 - </div> 236 - </details> 237 - {{ end }} 238 - {{ end }} 239 - 240 - {{ define "ownRepos" }} 241 - <div> 242 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 243 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 244 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 245 - <span>PINNED REPOS</span> 246 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 247 - view all {{ i "chevron-right" "w-4 h-4" }} 248 - </span> 249 - </a> 250 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 251 - <button 252 - hx-get="profile/edit-pins" 253 - hx-target="#all-repos" 254 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 255 - {{ i "pencil" "w-3 h-3" }} 256 - edit 257 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 258 - </button> 259 - {{ end }} 260 - </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 262 - {{ range .Repos }} 263 - {{ template "user/fragments/repoCard" (list $ . false) }} 264 - {{ else }} 265 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 266 - {{ end }} 267 - </div> 268 - </div> 269 - {{ end }} 270 - 271 - {{ define "collaboratingRepos" }} 272 - {{ if gt (len .CollaboratingRepos) 0 }} 273 - <div> 274 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 275 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 276 - {{ range .CollaboratingRepos }} 277 - {{ template "user/fragments/repoCard" (list $ . true) }} 278 - {{ else }} 279 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 280 - {{ end }} 281 - </div> 282 - </div> 283 - {{ end }} 284 - {{ end }} 285 - 286 - {{ define "punchcard" }} 287 - {{ $now := now }} 288 - <div> 289 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 290 - PUNCHCARD 291 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 292 - {{ .Total | int64 | commaFmt }} commits 293 - </span> 294 - </p> 295 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 296 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 297 - {{ range .Punches }} 298 - {{ $count := .Count }} 299 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 300 - {{ if lt $count 1 }} 301 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 302 - {{ else if lt $count 2 }} 303 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 304 - {{ else if lt $count 4 }} 305 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 306 - {{ else if lt $count 8 }} 307 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 308 - {{ else }} 309 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 310 - {{ end }} 311 - 312 - {{ if .Date.After $now }} 313 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 314 - {{ end }} 315 - <div class="w-full h-full flex justify-center items-center"> 316 - <div 317 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 318 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 319 - </div> 320 - </div> 321 - {{ end }} 322 - </div> 323 - </div> 324 - </div> 325 - {{ end }}
+7 -18
appview/pages/templates/user/repos.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 11 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 14 + </div> 26 15 {{ else }} 27 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 17 {{ end }}
+94
appview/pages/templates/user/settings/emails.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "emailSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "emailSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Commits authored using emails listed here will be associated with your Tangled profile. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + {{ template "addEmailButton" . }} 29 + </div> 30 + </div> 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + {{ range .Emails }} 33 + {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 + {{ else }} 35 + <div class="flex items-center justify-center p-2 text-gray-500"> 36 + no emails added yet 37 + </div> 38 + {{ end }} 39 + </div> 40 + {{ end }} 41 + 42 + {{ define "addEmailButton" }} 43 + <button 44 + class="btn flex items-center gap-2" 45 + popovertarget="add-email-modal" 46 + popovertargetaction="toggle"> 47 + {{ i "plus" "size-4" }} 48 + add email 49 + </button> 50 + <div 51 + id="add-email-modal" 52 + popover 53 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 + {{ template "addEmailModal" . }} 55 + </div> 56 + {{ end}} 57 + 58 + {{ define "addEmailModal" }} 59 + <form 60 + hx-put="/settings/emails" 61 + hx-indicator="#spinner" 62 + hx-swap="none" 63 + class="flex flex-col gap-2" 64 + > 65 + <p class="uppercase p-0">ADD EMAIL</p> 66 + <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 + <input 68 + type="email" 69 + id="email-address" 70 + name="email" 71 + required 72 + placeholder="your@email.com" 73 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 + /> 75 + <div class="flex gap-2 pt-2"> 76 + <button 77 + type="button" 78 + popovertarget="add-email-modal" 79 + popovertargetaction="hide" 80 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 + > 82 + {{ i "x" "size-4" }} cancel 83 + </button> 84 + <button type="submit" class="btn w-1/2 flex items-center"> 85 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 + <span id="spinner" class="group"> 87 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </span> 89 + </button> 90 + </div> 91 + <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 + <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 + </form> 94 + {{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
··· 1 + {{ define "user/settings/fragments/emailListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $email := index . 1 }} 4 + <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 + <span class="font-bold"> 9 + {{ $email.Address }} 10 + </span> 11 + <div class="inline-flex items-center gap-1"> 12 + {{ if $email.Verified }} 13 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 + {{ else }} 15 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 + {{ end }} 17 + {{ if $email.Primary }} 18 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 + {{ end }} 20 + </div> 21 + </div> 22 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 + <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 + </div> 25 + </div> 26 + <div class="flex gap-2 items-center"> 27 + {{ if not $email.Verified }} 28 + <button 29 + class="btn flex gap-2 text-sm px-2 py-1" 30 + hx-post="/settings/emails/verify/resend" 31 + hx-swap="none" 32 + hx-vals='{"email": "{{ $email.Address }}"}'> 33 + {{ i "rotate-cw" "w-4 h-4" }} 34 + <span class="hidden md:inline">resend</span> 35 + </button> 36 + {{ end }} 37 + {{ if and (not $email.Primary) $email.Verified }} 38 + <button 39 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 + hx-post="/settings/emails/primary" 41 + hx-swap="none" 42 + hx-vals='{"email": "{{ $email.Address }}"}'> 43 + set as primary 44 + </button> 45 + {{ end }} 46 + {{ if not $email.Primary }} 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete email" 50 + hx-delete="/settings/emails" 51 + hx-swap="none" 52 + hx-vals='{"email": "{{ $email.Address }}"}' 53 + hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 + > 55 + {{ i "trash-2" "w-5 h-5" }} 56 + <span class="hidden md:inline">delete</span> 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
··· 1 + {{ define "user/settings/fragments/keyListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $key := index . 1 }} 4 + <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + <span>{{ i "key" "w-4" "h-4" }}</span> 8 + <span class="font-bold"> 9 + {{ $key.Name }} 10 + </span> 11 + </div> 12 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 + {{ sshFingerprint $key.Key }} 14 + </span> 15 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 + <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 + </div> 18 + </div> 19 + <button 20 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 + title="Delete key" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 + hx-swap="none" 24 + hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 + > 26 + {{ i "trash-2" "w-5 h-5" }} 27 + <span class="hidden md:inline">delete</span> 28 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + </button> 30 + </div> 31 + {{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+101
appview/pages/templates/user/settings/keys.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "sshKeysSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "sshKeysSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + SSH public keys added here will be broadcasted to knots that you are a member of, 25 + allowing you to push to repositories there. 26 + </p> 27 + </div> 28 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 + {{ template "addKeyButton" . }} 30 + </div> 31 + </div> 32 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 + {{ range .PubKeys }} 34 + {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 + {{ else }} 36 + <div class="flex items-center justify-center p-2 text-gray-500"> 37 + no keys added yet 38 + </div> 39 + {{ end }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "addKeyButton" }} 44 + <button 45 + class="btn flex items-center gap-2" 46 + popovertarget="add-key-modal" 47 + popovertargetaction="toggle"> 48 + {{ i "plus" "size-4" }} 49 + add key 50 + </button> 51 + <div 52 + id="add-key-modal" 53 + popover 54 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 + {{ template "addKeyModal" . }} 56 + </div> 57 + {{ end}} 58 + 59 + {{ define "addKeyModal" }} 60 + <form 61 + hx-put="/settings/keys" 62 + hx-indicator="#spinner" 63 + hx-swap="none" 64 + class="flex flex-col gap-2" 65 + > 66 + <p class="uppercase p-0">ADD SSH KEY</p> 67 + <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 + <input 69 + type="text" 70 + id="key-name" 71 + name="name" 72 + required 73 + placeholder="key name" 74 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 + /> 76 + <textarea 77 + type="text" 78 + id="key-value" 79 + name="key" 80 + required 81 + placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 + <div class="flex gap-2 pt-2"> 84 + <button 85 + type="button" 86 + popovertarget="add-key-modal" 87 + popovertargetaction="hide" 88 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 + > 90 + {{ i "x" "size-4" }} cancel 91 + </button> 92 + <button type="submit" class="btn w-1/2 flex items-center"> 93 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 + <span id="spinner" class="group"> 95 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 + </span> 97 + </button> 98 + </div> 99 + <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 + </form> 101 + {{ end }}
+64
appview/pages/templates/user/settings/profile.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "profileInfo" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "profileInfo" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Your account information from your AT Protocol identity. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + </div> 29 + </div> 30 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 + <div class="flex items-center justify-between p-4"> 32 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 + <span>Handle</span> 35 + </div> 36 + {{ if .LoggedInUser.Handle }} 37 + <span class="font-bold"> 38 + @{{ .LoggedInUser.Handle }} 39 + </span> 40 + {{ end }} 41 + </div> 42 + </div> 43 + <div class="flex items-center justify-between p-4"> 44 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 46 + <span>Decentralized Identifier (DID)</span> 47 + </div> 48 + <span class="font-mono font-bold"> 49 + {{ .LoggedInUser.Did }} 50 + </span> 51 + </div> 52 + </div> 53 + <div class="flex items-center justify-between p-4"> 54 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 56 + <span>Personal Data Server (PDS)</span> 57 + </div> 58 + <span class="font-bold"> 59 + {{ .LoggedInUser.Pds }} 60 + </span> 61 + </div> 62 + </div> 63 + </div> 64 + {{ end }}
+1 -1
appview/pages/templates/user/signup.html
··· 42 42 </button> 43 43 </form> 44 44 <p class="text-sm text-gray-500"> 45 - Already have an account? <a href="/login" class="underline">Login to Tangled</a>. 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 46 </p> 47 47 48 48 <p id="signup-msg" class="error w-full"></p>
+19
appview/pages/templates/user/starred.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+45
appview/pages/templates/user/strings.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['ยท']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+172 -198
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "log" 10 8 "net/http" 11 9 "sort" ··· 19 17 "tangled.sh/tangled.sh/core/appview/notify" 20 18 "tangled.sh/tangled.sh/core/appview/oauth" 21 19 "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 23 "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" ··· 28 28 29 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - "github.com/bluesky-social/indigo/atproto/syntax" 32 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 33 33 "github.com/go-chi/chi/v5" 34 34 "github.com/google/uuid" 35 35 ) ··· 96 96 return 97 97 } 98 98 99 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 99 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 100 resubmitResult := pages.Unknown 101 101 if user.Did == pull.OwnerDid { 102 102 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 151 151 } 152 152 } 153 153 154 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 155 - didHandleMap := make(map[string]string) 156 - for _, identity := range resolvedIds { 157 - if !identity.Handle.IsInvalidHandle() { 158 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 159 - } else { 160 - didHandleMap[identity.DID.String()] = identity.DID.String() 161 - } 162 - } 163 - 164 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 165 155 resubmitResult := pages.Unknown 166 156 if user != nil && user.Did == pull.OwnerDid { 167 157 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 212 202 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 213 203 LoggedInUser: user, 214 204 RepoInfo: repoInfo, 215 - DidHandleMap: didHandleMap, 216 205 Pull: pull, 217 206 Stack: stack, 218 207 AbandonedPulls: abandonedPulls, ··· 226 215 }) 227 216 } 228 217 229 - func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 218 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 230 219 if pull.State == db.PullMerged { 231 220 return types.MergeCheckResponse{} 232 221 } 233 222 234 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 235 - if err != nil { 236 - log.Printf("failed to get registration key: %v", err) 237 - return types.MergeCheckResponse{ 238 - Error: "failed to check merge status: this knot is unregistered", 239 - } 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 240 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 241 228 242 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 243 - if err != nil { 244 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 245 - return types.MergeCheckResponse{ 246 - Error: "failed to check merge status", 247 - } 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 248 231 } 249 232 250 233 patch := pull.LatestPatch() ··· 257 240 patch = mergeable.CombinedPatch() 258 241 } 259 242 260 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 261 - if err != nil { 262 - log.Println("failed to check for mergeability:", err) 243 + resp, xe := tangled.RepoMergeCheck( 244 + r.Context(), 245 + &xrpcc, 246 + &tangled.RepoMergeCheck_Input{ 247 + Did: f.OwnerDid(), 248 + Name: f.Name, 249 + Branch: pull.TargetBranch, 250 + Patch: patch, 251 + }, 252 + ) 253 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 + log.Println("failed to check for mergeability", "err", err) 263 255 return types.MergeCheckResponse{ 264 - Error: "failed to check merge status", 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 265 257 } 266 258 } 267 - switch resp.StatusCode { 268 - case 404: 269 - return types.MergeCheckResponse{ 270 - Error: "failed to check merge status: this knot does not support PRs", 271 - } 272 - case 400: 273 - return types.MergeCheckResponse{ 274 - Error: "failed to check merge status: does this knot support PRs?", 259 + 260 + // convert xrpc response to internal types 261 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 + for i, conflict := range resp.Conflicts { 263 + conflicts[i] = types.ConflictInfo{ 264 + Filename: conflict.Filename, 265 + Reason: conflict.Reason, 275 266 } 276 267 } 277 268 278 - respBody, err := io.ReadAll(resp.Body) 279 - if err != nil { 280 - log.Println("failed to read merge check response body") 281 - return types.MergeCheckResponse{ 282 - Error: "failed to check merge status: knot is not speaking the right language", 283 - } 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 272 + } 273 + 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 284 276 } 285 - defer resp.Body.Close() 286 277 287 - var mergeCheckResponse types.MergeCheckResponse 288 - err = json.Unmarshal(respBody, &mergeCheckResponse) 289 - if err != nil { 290 - log.Println("failed to unmarshal merge check response", err) 291 - return types.MergeCheckResponse{ 292 - Error: "failed to check merge status: knot is not speaking the right language", 293 - } 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 294 280 } 295 281 296 - return mergeCheckResponse 282 + return result 297 283 } 298 284 299 285 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 318 304 // pulls within the same repo 319 305 knot = f.Knot 320 306 ownerDid = f.OwnerDid() 321 - repoName = f.RepoName 307 + repoName = f.Name 322 308 } 323 309 324 310 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) ··· 377 363 return 378 364 } 379 365 380 - identsToResolve := []string{pull.OwnerDid} 381 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 382 - didHandleMap := make(map[string]string) 383 - for _, identity := range resolvedIds { 384 - if !identity.Handle.IsInvalidHandle() { 385 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 386 - } else { 387 - didHandleMap[identity.DID.String()] = identity.DID.String() 388 - } 389 - } 390 - 391 366 patch := pull.Submissions[roundIdInt].Patch 392 367 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 393 368 394 369 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 395 370 LoggedInUser: user, 396 - DidHandleMap: didHandleMap, 397 371 RepoInfo: f.RepoInfo(user), 398 372 Pull: pull, 399 373 Stack: stack, ··· 440 414 return 441 415 } 442 416 443 - identsToResolve := []string{pull.OwnerDid} 444 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 445 - didHandleMap := make(map[string]string) 446 - for _, identity := range resolvedIds { 447 - if !identity.Handle.IsInvalidHandle() { 448 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 449 - } else { 450 - didHandleMap[identity.DID.String()] = identity.DID.String() 451 - } 452 - } 453 - 454 417 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 455 418 if err != nil { 456 419 log.Println("failed to interdiff; current patch malformed") ··· 472 435 RepoInfo: f.RepoInfo(user), 473 436 Pull: pull, 474 437 Round: roundIdInt, 475 - DidHandleMap: didHandleMap, 476 438 Interdiff: interdiff, 477 439 DiffOpts: diffOpts, 478 440 }) ··· 494 456 return 495 457 } 496 458 497 - identsToResolve := []string{pull.OwnerDid} 498 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 499 - didHandleMap := make(map[string]string) 500 - for _, identity := range resolvedIds { 501 - if !identity.Handle.IsInvalidHandle() { 502 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 503 - } else { 504 - didHandleMap[identity.DID.String()] = identity.DID.String() 505 - } 506 - } 507 - 508 459 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 509 460 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 510 461 } ··· 529 480 530 481 pulls, err := db.GetPulls( 531 482 s.db, 532 - db.FilterEq("repo_at", f.RepoAt), 483 + db.FilterEq("repo_at", f.RepoAt()), 533 484 db.FilterEq("state", state), 534 485 ) 535 486 if err != nil { ··· 595 546 m[p.Sha] = p 596 547 } 597 548 598 - identsToResolve := make([]string, len(pulls)) 599 - for i, pull := range pulls { 600 - identsToResolve[i] = pull.OwnerDid 601 - } 602 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 603 - didHandleMap := make(map[string]string) 604 - for _, identity := range resolvedIds { 605 - if !identity.Handle.IsInvalidHandle() { 606 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 607 - } else { 608 - didHandleMap[identity.DID.String()] = identity.DID.String() 609 - } 610 - } 611 - 612 549 s.pages.RepoPulls(w, pages.RepoPullsParams{ 613 550 LoggedInUser: s.oauth.GetUser(r), 614 551 RepoInfo: f.RepoInfo(user), 615 552 Pulls: pulls, 616 - DidHandleMap: didHandleMap, 617 553 FilteringBy: state, 618 554 Stacks: stacks, 619 555 Pipelines: m, ··· 669 605 defer tx.Rollback() 670 606 671 607 createdAt := time.Now().Format(time.RFC3339) 672 - ownerDid := user.Did 673 608 674 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 609 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 675 610 if err != nil { 676 611 log.Println("failed to get pull at", err) 677 612 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 678 613 return 679 614 } 680 615 681 - atUri := f.RepoAt.String() 682 616 client, err := s.oauth.AuthorizedClient(r) 683 617 if err != nil { 684 618 log.Println("failed to get authorized client", err) ··· 691 625 Rkey: tid.TID(), 692 626 Record: &lexutil.LexiconTypeDecoder{ 693 627 Val: &tangled.RepoPullComment{ 694 - Repo: &atUri, 695 628 Pull: string(pullAt), 696 - Owner: &ownerDid, 697 629 Body: body, 698 630 CreatedAt: createdAt, 699 631 }, ··· 707 639 708 640 comment := &db.PullComment{ 709 641 OwnerDid: user.Did, 710 - RepoAt: f.RepoAt.String(), 642 + RepoAt: f.RepoAt().String(), 711 643 PullId: pull.PullId, 712 644 Body: body, 713 645 CommentAt: atResp.Uri, ··· 753 685 return 754 686 } 755 687 756 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 688 + result, err := us.Branches(f.OwnerDid(), f.Name) 757 689 if err != nil { 758 690 log.Println("failed to fetch branches", err) 759 691 return ··· 801 733 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 802 734 return 803 735 } 736 + sanitizer := markup.NewSanitizer() 737 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 738 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 739 + return 740 + } 804 741 } 805 742 806 743 // Validate we have at least one valid PR creation method ··· 877 814 return 878 815 } 879 816 880 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 817 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 881 818 if err != nil { 882 819 log.Println("failed to compare", err) 883 820 s.pages.Notice(w, "pull", err.Error()) ··· 913 850 } 914 851 915 852 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 916 - fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 853 + repoString := strings.SplitN(forkRepo, "/", 2) 854 + forkOwnerDid := repoString[0] 855 + repoName := repoString[1] 856 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 917 857 if errors.Is(err, sql.ErrNoRows) { 918 858 s.pages.Notice(w, "pull", "No such fork.") 919 859 return ··· 923 863 return 924 864 } 925 865 926 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 927 - if err != nil { 928 - log.Println("failed to fetch registration key:", err) 929 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 930 - return 931 - } 932 - 933 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 866 + client, err := s.oauth.ServiceClient( 867 + r, 868 + oauth.WithService(fork.Knot), 869 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 870 + oauth.WithDev(s.config.Core.Dev), 871 + ) 934 872 if err != nil { 935 - log.Println("failed to create signed client:", err) 873 + log.Printf("failed to connect to knot server: %v", err) 936 874 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 937 875 return 938 876 } ··· 944 882 return 945 883 } 946 884 947 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 948 - if err != nil { 949 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 950 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 885 + resp, err := tangled.RepoHiddenRef( 886 + r.Context(), 887 + client, 888 + &tangled.RepoHiddenRef_Input{ 889 + ForkRef: sourceBranch, 890 + RemoteRef: targetBranch, 891 + Repo: fork.RepoAt().String(), 892 + }, 893 + ) 894 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 895 + s.pages.Notice(w, "pull", err.Error()) 951 896 return 952 897 } 953 898 954 - switch resp.StatusCode { 955 - case 404: 956 - case 400: 957 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 899 + if !resp.Success { 900 + errorMsg := "Failed to create pull request" 901 + if resp.Error != nil { 902 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 903 + } 904 + s.pages.Notice(w, "pull", errorMsg) 958 905 return 959 906 } 960 907 ··· 964 911 // hiddenRef: hidden/feature-1/main (on repo-fork) 965 912 // targetBranch: main (on repo-1) 966 913 // sourceBranch: feature-1 (on repo-fork) 967 - comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 914 + comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch) 968 915 if err != nil { 969 916 log.Println("failed to compare across branches", err) 970 917 s.pages.Notice(w, "pull", err.Error()) ··· 979 926 return 980 927 } 981 928 982 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 983 - if err != nil { 984 - log.Println("failed to parse fork AT URI", err) 985 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 986 - return 987 - } 929 + forkAtUri := fork.RepoAt() 930 + forkAtUriStr := forkAtUri.String() 988 931 989 932 pullSource := &db.PullSource{ 990 933 Branch: sourceBranch, ··· 992 935 } 993 936 recordPullSource := &tangled.RepoPull_Source{ 994 937 Branch: sourceBranch, 995 - Repo: &fork.AtUri, 938 + Repo: &forkAtUriStr, 996 939 Sha: sourceRev, 997 940 } 998 941 ··· 1068 1011 Body: body, 1069 1012 TargetBranch: targetBranch, 1070 1013 OwnerDid: user.Did, 1071 - RepoAt: f.RepoAt, 1014 + RepoAt: f.RepoAt(), 1072 1015 Rkey: rkey, 1073 1016 Submissions: []*db.PullSubmission{ 1074 1017 &initialSubmission, ··· 1081 1024 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1082 1025 return 1083 1026 } 1084 - pullId, err := db.NextPullId(tx, f.RepoAt) 1027 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1085 1028 if err != nil { 1086 1029 log.Println("failed to get pull id", err) 1087 1030 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1094 1037 Rkey: rkey, 1095 1038 Record: &lexutil.LexiconTypeDecoder{ 1096 1039 Val: &tangled.RepoPull{ 1097 - Title: title, 1098 - PullId: int64(pullId), 1099 - TargetRepo: string(f.RepoAt), 1100 - TargetBranch: targetBranch, 1101 - Patch: patch, 1102 - Source: recordPullSource, 1040 + Title: title, 1041 + Target: &tangled.RepoPull_Target{ 1042 + Repo: string(f.RepoAt()), 1043 + Branch: targetBranch, 1044 + }, 1045 + Patch: patch, 1046 + Source: recordPullSource, 1103 1047 }, 1104 1048 }, 1105 1049 }) ··· 1274 1218 return 1275 1219 } 1276 1220 1277 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1221 + result, err := us.Branches(f.OwnerDid(), f.Name) 1278 1222 if err != nil { 1279 1223 log.Println("failed to reach knotserver", err) 1280 1224 return ··· 1330 1274 } 1331 1275 1332 1276 forkVal := r.URL.Query().Get("fork") 1333 - 1277 + repoString := strings.SplitN(forkVal, "/", 2) 1278 + forkOwnerDid := repoString[0] 1279 + forkName := repoString[1] 1334 1280 // fork repo 1335 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1281 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1336 1282 if err != nil { 1337 1283 log.Println("failed to get repo", user.Did, forkVal) 1338 1284 return ··· 1345 1291 return 1346 1292 } 1347 1293 1348 - sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1294 + sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name) 1349 1295 if err != nil { 1350 1296 log.Println("failed to reach knotserver for source branches", err) 1351 1297 return ··· 1358 1304 return 1359 1305 } 1360 1306 1361 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1307 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1362 1308 if err != nil { 1363 1309 log.Println("failed to reach knotserver for target branches", err) 1364 1310 return ··· 1474 1420 return 1475 1421 } 1476 1422 1477 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1423 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1478 1424 if err != nil { 1479 1425 log.Printf("compare request failed: %s", err) 1480 1426 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1524 1470 return 1525 1471 } 1526 1472 1527 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1473 + // update the hidden tracking branch to latest 1474 + client, err := s.oauth.ServiceClient( 1475 + r, 1476 + oauth.WithService(forkRepo.Knot), 1477 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1478 + oauth.WithDev(s.config.Core.Dev), 1479 + ) 1528 1480 if err != nil { 1529 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1530 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1481 + log.Printf("failed to connect to knot server: %v", err) 1531 1482 return 1532 1483 } 1533 1484 1534 - // update the hidden tracking branch to latest 1535 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1536 - if err != nil { 1537 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1538 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1485 + resp, err := tangled.RepoHiddenRef( 1486 + r.Context(), 1487 + client, 1488 + &tangled.RepoHiddenRef_Input{ 1489 + ForkRef: pull.PullSource.Branch, 1490 + RemoteRef: pull.TargetBranch, 1491 + Repo: forkRepo.RepoAt().String(), 1492 + }, 1493 + ) 1494 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1495 + s.pages.Notice(w, "resubmit-error", err.Error()) 1539 1496 return 1540 1497 } 1541 - 1542 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1543 - if err != nil || resp.StatusCode != http.StatusNoContent { 1544 - log.Printf("failed to update tracking branch: %s", err) 1545 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1498 + if !resp.Success { 1499 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1500 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1546 1501 return 1547 1502 } 1548 1503 ··· 1656 1611 SwapRecord: ex.Cid, 1657 1612 Record: &lexutil.LexiconTypeDecoder{ 1658 1613 Val: &tangled.RepoPull{ 1659 - Title: pull.Title, 1660 - PullId: int64(pull.PullId), 1661 - TargetRepo: string(f.RepoAt), 1662 - TargetBranch: pull.TargetBranch, 1663 - Patch: patch, // new patch 1664 - Source: recordPullSource, 1614 + Title: pull.Title, 1615 + Target: &tangled.RepoPull_Target{ 1616 + Repo: string(f.RepoAt()), 1617 + Branch: pull.TargetBranch, 1618 + }, 1619 + Patch: patch, // new patch 1620 + Source: recordPullSource, 1665 1621 }, 1666 1622 }, 1667 1623 }) ··· 1774 1730 1775 1731 // deleted pulls are marked as deleted in the DB 1776 1732 for _, p := range deletions { 1733 + // do not do delete already merged PRs 1734 + if p.State == db.PullMerged { 1735 + continue 1736 + } 1737 + 1777 1738 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1778 1739 if err != nil { 1779 1740 log.Println("failed to delete pull", err, p.PullId) ··· 1814 1775 op, _ := origById[id] 1815 1776 np, _ := newById[id] 1816 1777 1778 + // do not update already merged PRs 1779 + if op.State == db.PullMerged { 1780 + continue 1781 + } 1782 + 1817 1783 submission := np.Submissions[np.LastRoundNumber()] 1818 1784 1819 1785 // resubmit the old pull ··· 1958 1924 1959 1925 patch := pullsToMerge.CombinedPatch() 1960 1926 1961 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1962 - if err != nil { 1963 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1964 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1965 - return 1966 - } 1967 - 1968 1927 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1969 1928 if err != nil { 1970 1929 log.Printf("resolving identity: %s", err) ··· 1977 1936 log.Printf("failed to get primary email: %s", err) 1978 1937 } 1979 1938 1980 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1981 - if err != nil { 1982 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1983 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1984 - return 1939 + authorName := ident.Handle.String() 1940 + mergeInput := &tangled.RepoMerge_Input{ 1941 + Did: f.OwnerDid(), 1942 + Name: f.Name, 1943 + Branch: pull.TargetBranch, 1944 + Patch: patch, 1945 + CommitMessage: &pull.Title, 1946 + AuthorName: &authorName, 1947 + } 1948 + 1949 + if pull.Body != "" { 1950 + mergeInput.CommitBody = &pull.Body 1951 + } 1952 + 1953 + if email.Address != "" { 1954 + mergeInput.AuthorEmail = &email.Address 1985 1955 } 1986 1956 1987 - // Merge the pull request 1988 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1957 + client, err := s.oauth.ServiceClient( 1958 + r, 1959 + oauth.WithService(f.Knot), 1960 + oauth.WithLxm(tangled.RepoMergeNSID), 1961 + oauth.WithDev(s.config.Core.Dev), 1962 + ) 1989 1963 if err != nil { 1990 - log.Printf("failed to merge pull request: %s", err) 1964 + log.Printf("failed to connect to knot server: %v", err) 1991 1965 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1992 1966 return 1993 1967 } 1994 1968 1995 - if resp.StatusCode != http.StatusOK { 1996 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1997 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1969 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 1970 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1971 + s.pages.Notice(w, "pull-merge-error", err.Error()) 1998 1972 return 1999 1973 } 2000 1974 ··· 2007 1981 defer tx.Rollback() 2008 1982 2009 1983 for _, p := range pullsToMerge { 2010 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1984 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 2011 1985 if err != nil { 2012 1986 log.Printf("failed to update pull request status in database: %s", err) 2013 1987 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2023 1997 return 2024 1998 } 2025 1999 2026 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 2000 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2027 2001 } 2028 2002 2029 2003 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2075 2049 2076 2050 for _, p := range pullsToClose { 2077 2051 // Close the pull in the database 2078 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2052 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2079 2053 if err != nil { 2080 2054 log.Println("failed to close pull", err) 2081 2055 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2143 2117 2144 2118 for _, p := range pullsToReopen { 2145 2119 // Close the pull in the database 2146 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2120 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2147 2121 if err != nil { 2148 2122 log.Println("failed to close pull", err) 2149 2123 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2195 2169 Body: body, 2196 2170 TargetBranch: targetBranch, 2197 2171 OwnerDid: user.Did, 2198 - RepoAt: f.RepoAt, 2172 + RepoAt: f.RepoAt(), 2199 2173 Rkey: rkey, 2200 2174 Submissions: []*db.PullSubmission{ 2201 2175 &initialSubmission,
+6 -6
appview/repo/artifact.go
··· 76 76 Artifact: uploadBlobResp.Blob, 77 77 CreatedAt: createdAt.Format(time.RFC3339), 78 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 79 + Repo: f.RepoAt().String(), 80 80 Tag: tag.Tag.Hash[:], 81 81 }, 82 82 }, ··· 100 100 artifact := db.Artifact{ 101 101 Did: user.Did, 102 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 103 + RepoAt: f.RepoAt(), 104 104 Tag: tag.Tag.Hash, 105 105 CreatedAt: createdAt, 106 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 155 156 156 artifacts, err := db.GetArtifact( 157 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 158 + db.FilterEq("repo_at", f.RepoAt()), 159 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 160 db.FilterEq("name", filename), 161 161 ) ··· 197 197 198 198 artifacts, err := db.GetArtifact( 199 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 200 + db.FilterEq("repo_at", f.RepoAt()), 201 201 db.FilterEq("tag", tag[:]), 202 202 db.FilterEq("name", filename), 203 203 ) ··· 239 239 defer tx.Rollback() 240 240 241 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 242 + db.FilterEq("repo_at", f.RepoAt()), 243 243 db.FilterEq("tag", artifact.Tag[:]), 244 244 db.FilterEq("name", filename), 245 245 ) ··· 270 270 return nil, err 271 271 } 272 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 + result, err := us.Tags(f.OwnerDid(), f.Name) 274 274 if err != nil { 275 275 log.Println("failed to reach knotserver", err) 276 276 return nil, err
+165
appview/repo/feed.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/reporesolver" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/feeds" 16 + ) 17 + 18 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 19 + const feedLimitPerType = 100 20 + 21 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + feed := &feeds.Feed{ 32 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 33 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 34 + Items: make([]*feeds.Item, 0), 35 + Updated: time.UnixMilli(0), 36 + } 37 + 38 + for _, pull := range pulls { 39 + items, err := rp.createPullItems(ctx, pull, f) 40 + if err != nil { 41 + return nil, err 42 + } 43 + feed.Items = append(feed.Items, items...) 44 + } 45 + 46 + for _, issue := range issues { 47 + item, err := rp.createIssueItem(ctx, issue, f) 48 + if err != nil { 49 + return nil, err 50 + } 51 + feed.Items = append(feed.Items, item) 52 + } 53 + 54 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 55 + if a.Created.After(b.Created) { 56 + return -1 57 + } 58 + return 1 59 + }) 60 + 61 + if len(feed.Items) > 0 { 62 + feed.Updated = feed.Items[0].Created 63 + } 64 + 65 + return feed, nil 66 + } 67 + 68 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + var items []*feeds.Item 75 + 76 + state := rp.getPullState(pull) 77 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 78 + 79 + mainItem := &feeds.Item{ 80 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 81 + Description: description, 82 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 83 + Created: pull.Created, 84 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 85 + } 86 + items = append(items, mainItem) 87 + 88 + for _, round := range pull.Submissions { 89 + if round == nil || round.RoundNumber == 0 { 90 + continue 91 + } 92 + 93 + roundItem := &feeds.Item{ 94 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 95 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 96 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 97 + Created: round.Created, 98 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 99 + } 100 + items = append(items, roundItem) 101 + } 102 + 103 + return items, nil 104 + } 105 + 106 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + state := "closed" 113 + if issue.Open { 114 + state = "opened" 115 + } 116 + 117 + return &feeds.Item{ 118 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 119 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 120 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 121 + Created: issue.Created, 122 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 123 + }, nil 124 + } 125 + 126 + func (rp *Repo) getPullState(pull *db.Pull) string { 127 + if pull.State == db.PullOpen { 128 + return "opened" 129 + } 130 + return pull.State.String() 131 + } 132 + 133 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 134 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 + 136 + if pull.State == db.PullMerged { 137 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 + } 139 + 140 + return fmt.Sprintf("%s in %s", base, repoName) 141 + } 142 + 143 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 144 + f, err := rp.repoResolver.Resolve(r) 145 + if err != nil { 146 + log.Println("failed to fully resolve repo:", err) 147 + return 148 + } 149 + 150 + feed, err := rp.getRepoFeed(r.Context(), f) 151 + if err != nil { 152 + log.Println("failed to get repo feed:", err) 153 + rp.pages.Error500(w) 154 + return 155 + } 156 + 157 + atom, err := feed.ToAtom() 158 + if err != nil { 159 + rp.pages.Error500(w) 160 + return 161 + } 162 + 163 + w.Header().Set("content-type", "application/atom+xml") 164 + w.Write([]byte(atom)) 165 + }
+17 -104
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "encoding/json" 5 - "fmt" 6 4 "log" 7 5 "net/http" 8 6 "slices" ··· 11 9 12 10 "tangled.sh/tangled.sh/core/appview/commitverify" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 12 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 14 "tangled.sh/tangled.sh/core/knotclient" 19 15 "tangled.sh/tangled.sh/core/types" ··· 24 20 25 21 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 22 ref := chi.URLParam(r, "ref") 23 + 27 24 f, err := rp.repoResolver.Resolve(r) 28 25 if err != nil { 29 26 log.Println("failed to fully resolve repo", err) ··· 37 34 return 38 35 } 39 36 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 37 + result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 38 if err != nil { 42 39 rp.pages.Error503(w) 43 40 log.Println("failed to reach knotserver", err) ··· 104 101 user := rp.oauth.GetUser(r) 105 102 repoInfo := f.RepoInfo(user) 106 103 107 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 108 - if err != nil { 109 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 110 - rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 111 - } 112 - 113 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 114 - if err != nil { 115 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 116 - return 117 - } 118 - 119 - var forkInfo *types.ForkInfo 120 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 121 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 - if err != nil { 123 - log.Printf("Failed to fetch fork information: %v", err) 124 - return 125 - } 126 - } 127 - 128 104 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 105 + languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 130 106 if err != nil { 131 107 log.Printf("failed to compute language percentages: %s", err) 132 108 // non-fatal ··· 143 119 } 144 120 145 121 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 146 - LoggedInUser: user, 147 - RepoInfo: repoInfo, 148 - TagMap: tagMap, 149 - RepoIndexResponse: *result, 150 - CommitsTrunc: commitsTrunc, 151 - TagsTrunc: tagsTrunc, 152 - ForkInfo: forkInfo, 122 + LoggedInUser: user, 123 + RepoInfo: repoInfo, 124 + TagMap: tagMap, 125 + RepoIndexResponse: *result, 126 + CommitsTrunc: commitsTrunc, 127 + TagsTrunc: tagsTrunc, 128 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 153 129 BranchesTrunc: branchesTrunc, 154 130 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 155 131 VerifiedCommits: vc, ··· 160 136 161 137 func (rp *Repo) getLanguageInfo( 162 138 f *reporesolver.ResolvedRepo, 163 - signedClient *knotclient.SignedClient, 139 + us *knotclient.UnsignedClient, 140 + currentRef string, 164 141 isDefaultRef bool, 165 142 ) ([]types.RepoLanguageDetails, error) { 166 143 // first attempt to fetch from db 167 144 langs, err := db.GetRepoLanguages( 168 145 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 146 + db.FilterEq("repo_at", f.RepoAt()), 147 + db.FilterEq("ref", currentRef), 171 148 ) 172 149 173 150 if err != nil || langs == nil { 174 151 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 152 + ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 176 153 if err != nil { 177 154 return nil, err 178 155 } ··· 182 159 183 160 for l, s := range ls.Languages { 184 161 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 162 + RepoAt: f.RepoAt(), 163 + Ref: currentRef, 187 164 IsDefaultRef: isDefaultRef, 188 165 Language: l, 189 166 Bytes: s, ··· 229 206 230 207 return languageStats, nil 231 208 } 232 - 233 - func getForkInfo( 234 - repoInfo repoinfo.RepoInfo, 235 - rp *Repo, 236 - f *reporesolver.ResolvedRepo, 237 - user *oauth.User, 238 - signedClient *knotclient.SignedClient, 239 - ) (*types.ForkInfo, error) { 240 - if user == nil { 241 - return nil, nil 242 - } 243 - 244 - forkInfo := types.ForkInfo{ 245 - IsFork: repoInfo.Source != nil, 246 - Status: types.UpToDate, 247 - } 248 - 249 - if !forkInfo.IsFork { 250 - forkInfo.IsFork = false 251 - return &forkInfo, nil 252 - } 253 - 254 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 255 - if err != nil { 256 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 257 - return nil, err 258 - } 259 - 260 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 261 - if err != nil { 262 - log.Println("failed to reach knotserver", err) 263 - return nil, err 264 - } 265 - 266 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 268 - }) { 269 - forkInfo.Status = types.MissingBranch 270 - return &forkInfo, nil 271 - } 272 - 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 274 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 - log.Printf("failed to update tracking branch: %s", err) 276 - return nil, err 277 - } 278 - 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 - 281 - var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 283 - if err != nil { 284 - log.Printf("failed to check if fork is ahead/behind: %s", err) 285 - return nil, err 286 - } 287 - 288 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 289 - log.Printf("failed to decode fork status: %s", err) 290 - return nil, err 291 - } 292 - 293 - forkInfo.Status = status.Status 294 - return &forkInfo, nil 295 - }
+327 -258
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 20 22 "tangled.sh/tangled.sh/core/api/tangled" 21 23 "tangled.sh/tangled.sh/core/appview/commitverify" 22 24 "tangled.sh/tangled.sh/core/appview/config" ··· 26 28 "tangled.sh/tangled.sh/core/appview/pages" 27 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 32 "tangled.sh/tangled.sh/core/eventconsumer" 30 33 "tangled.sh/tangled.sh/core/idresolver" 31 34 "tangled.sh/tangled.sh/core/knotclient" ··· 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 35 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 36 40 37 41 securejoin "github.com/cyphar/filepath-securejoin" 38 42 "github.com/go-chi/chi/v5" 39 43 "github.com/go-git/go-git/v5/plumbing" 40 44 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 45 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 46 ) 45 47 46 48 type Repo struct { ··· 54 56 enforcer *rbac.Enforcer 55 57 notifier notify.Notifier 56 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 57 60 } 58 61 59 62 func New( ··· 81 84 } 82 85 } 83 86 87 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + refParam := chi.URLParam(r, "ref") 89 + f, err := rp.repoResolver.Resolve(r) 90 + if err != nil { 91 + log.Println("failed to get repo and knot", err) 92 + return 93 + } 94 + 95 + var uri string 96 + if rp.config.Core.Dev { 97 + uri = "http" 98 + } else { 99 + uri = "https" 100 + } 101 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 102 + 103 + http.Redirect(w, r, url, http.StatusFound) 104 + } 105 + 84 106 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 85 107 f, err := rp.repoResolver.Resolve(r) 86 108 if err != nil { ··· 104 126 return 105 127 } 106 128 107 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 129 + repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 108 130 if err != nil { 131 + rp.pages.Error503(w) 109 132 log.Println("failed to reach knotserver", err) 110 133 return 111 134 } 112 135 113 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 136 + tagResult, err := us.Tags(f.OwnerDid(), f.Name) 114 137 if err != nil { 138 + rp.pages.Error503(w) 115 139 log.Println("failed to reach knotserver", err) 116 140 return 117 141 } ··· 125 149 tagMap[hash] = append(tagMap[hash], tag.Name) 126 150 } 127 151 128 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 152 + branchResult, err := us.Branches(f.OwnerDid(), f.Name) 129 153 if err != nil { 154 + rp.pages.Error503(w) 130 155 log.Println("failed to reach knotserver", err) 131 156 return 132 157 } ··· 193 218 return 194 219 } 195 220 196 - repoAt := f.RepoAt 221 + repoAt := f.RepoAt() 197 222 rkey := repoAt.RecordKey().String() 198 223 if rkey == "" { 199 224 log.Println("invalid aturi for repo", err) ··· 243 268 Record: &lexutil.LexiconTypeDecoder{ 244 269 Val: &tangled.Repo{ 245 270 Knot: f.Knot, 246 - Name: f.RepoName, 271 + Name: f.Name, 247 272 Owner: user.Did, 248 - CreatedAt: f.CreatedAt, 273 + CreatedAt: f.Created.Format(time.RFC3339), 249 274 Description: &newDescription, 250 275 Spindle: &f.Spindle, 251 276 }, ··· 291 316 return 292 317 } 293 318 294 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 319 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 295 320 if err != nil { 321 + rp.pages.Error503(w) 296 322 log.Println("failed to reach knotserver", err) 297 323 return 298 324 } ··· 356 382 if !rp.config.Core.Dev { 357 383 protocol = "https" 358 384 } 359 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 385 + 386 + // if the tree path has a trailing slash, let's strip it 387 + // so we don't 404 388 + treePath = strings.TrimSuffix(treePath, "/") 389 + 390 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 360 391 if err != nil { 392 + rp.pages.Error503(w) 361 393 log.Println("failed to reach knotserver", err) 362 394 return 363 395 } 364 396 397 + // uhhh so knotserver returns a 500 if the entry isn't found in 398 + // the requested tree path, so let's stick to not-OK here. 399 + // we can fix this once we build out the xrpc apis for these operations. 400 + if resp.StatusCode != http.StatusOK { 401 + rp.pages.Error404(w) 402 + return 403 + } 404 + 365 405 body, err := io.ReadAll(resp.Body) 366 406 if err != nil { 367 407 log.Printf("Error reading response body: %v", err) ··· 386 426 user := rp.oauth.GetUser(r) 387 427 388 428 var breadcrumbs [][]string 389 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 429 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 390 430 if treePath != "" { 391 431 for idx, elem := range strings.Split(treePath, "/") { 392 432 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 417 457 return 418 458 } 419 459 420 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 460 + result, err := us.Tags(f.OwnerDid(), f.Name) 421 461 if err != nil { 462 + rp.pages.Error503(w) 422 463 log.Println("failed to reach knotserver", err) 423 464 return 424 465 } 425 466 426 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 467 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 427 468 if err != nil { 428 469 log.Println("failed grab artifacts", err) 429 470 return ··· 474 515 return 475 516 } 476 517 477 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 518 + result, err := us.Branches(f.OwnerDid(), f.Name) 478 519 if err != nil { 520 + rp.pages.Error503(w) 479 521 log.Println("failed to reach knotserver", err) 480 522 return 481 523 } ··· 503 545 if !rp.config.Core.Dev { 504 546 protocol = "https" 505 547 } 506 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 548 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 507 549 if err != nil { 550 + rp.pages.Error503(w) 508 551 log.Println("failed to reach knotserver", err) 509 552 return 510 553 } 511 554 555 + if resp.StatusCode == http.StatusNotFound { 556 + rp.pages.Error404(w) 557 + return 558 + } 559 + 512 560 body, err := io.ReadAll(resp.Body) 513 561 if err != nil { 514 562 log.Printf("Error reading response body: %v", err) ··· 523 571 } 524 572 525 573 var breadcrumbs [][]string 526 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 574 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 527 575 if filePath != "" { 528 576 for idx, elem := range strings.Split(filePath, "/") { 529 577 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 556 604 557 605 // fetch the actual binary content like in RepoBlobRaw 558 606 559 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 607 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 560 608 contentSrc = blobURL 561 609 if !rp.config.Core.Dev { 562 610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) ··· 593 641 if !rp.config.Core.Dev { 594 642 protocol = "https" 595 643 } 596 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 597 - resp, err := http.Get(blobURL) 644 + 645 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 646 + 647 + req, err := http.NewRequest("GET", blobURL, nil) 648 + if err != nil { 649 + log.Println("failed to create request", err) 650 + return 651 + } 652 + 653 + // forward the If-None-Match header 654 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 655 + req.Header.Set("If-None-Match", clientETag) 656 + } 657 + 658 + client := &http.Client{} 659 + resp, err := client.Do(req) 598 660 if err != nil { 599 - log.Println("failed to reach knotserver:", err) 661 + log.Println("failed to reach knotserver", err) 600 662 rp.pages.Error503(w) 601 663 return 602 664 } 603 665 defer resp.Body.Close() 666 + 667 + // forward 304 not modified 668 + if resp.StatusCode == http.StatusNotModified { 669 + w.WriteHeader(http.StatusNotModified) 670 + return 671 + } 604 672 605 673 if resp.StatusCode != http.StatusOK { 606 674 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) ··· 649 717 return 650 718 } 651 719 652 - repoAt := f.RepoAt 720 + repoAt := f.RepoAt() 653 721 rkey := repoAt.RecordKey().String() 654 722 if rkey == "" { 655 723 fail("Failed to resolve repo. Try again later", err) ··· 657 725 } 658 726 659 727 newSpindle := r.FormValue("spindle") 728 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 660 729 client, err := rp.oauth.AuthorizedClient(r) 661 730 if err != nil { 662 731 fail("Failed to authorize. Try again later.", err) 663 732 return 664 733 } 665 734 666 - // ensure that this is a valid spindle for this user 667 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 668 - if err != nil { 669 - fail("Failed to find spindles. Try again later.", err) 670 - return 735 + if !removingSpindle { 736 + // ensure that this is a valid spindle for this user 737 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 738 + if err != nil { 739 + fail("Failed to find spindles. Try again later.", err) 740 + return 741 + } 742 + 743 + if !slices.Contains(validSpindles, newSpindle) { 744 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 745 + return 746 + } 671 747 } 672 748 673 - if !slices.Contains(validSpindles, newSpindle) { 674 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 675 - return 749 + spindlePtr := &newSpindle 750 + if removingSpindle { 751 + spindlePtr = nil 676 752 } 677 753 678 754 // optimistic update 679 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 755 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 680 756 if err != nil { 681 757 fail("Failed to update spindle. Try again later.", err) 682 758 return ··· 695 771 Record: &lexutil.LexiconTypeDecoder{ 696 772 Val: &tangled.Repo{ 697 773 Knot: f.Knot, 698 - Name: f.RepoName, 774 + Name: f.Name, 699 775 Owner: user.Did, 700 - CreatedAt: f.CreatedAt, 776 + CreatedAt: f.Created.Format(time.RFC3339), 701 777 Description: &f.Description, 702 - Spindle: &newSpindle, 778 + Spindle: spindlePtr, 703 779 }, 704 780 }, 705 781 }) ··· 709 785 return 710 786 } 711 787 712 - // add this spindle to spindle stream 713 - rp.spindlestream.AddSource( 714 - context.Background(), 715 - eventconsumer.NewSpindleSource(newSpindle), 716 - ) 788 + if !removingSpindle { 789 + // add this spindle to spindle stream 790 + rp.spindlestream.AddSource( 791 + context.Background(), 792 + eventconsumer.NewSpindleSource(newSpindle), 793 + ) 794 + } 717 795 718 796 rp.pages.HxRefresh(w) 719 797 } ··· 776 854 Record: &lexutil.LexiconTypeDecoder{ 777 855 Val: &tangled.RepoCollaborator{ 778 856 Subject: collaboratorIdent.DID.String(), 779 - Repo: string(f.RepoAt), 857 + Repo: string(f.RepoAt()), 780 858 CreatedAt: createdAt.Format(time.RFC3339), 781 859 }}, 782 860 }) ··· 785 863 fail("Failed to write record to PDS.", err) 786 864 return 787 865 } 788 - l = l.With("at-uri", resp.Uri) 866 + 867 + aturi := resp.Uri 868 + l = l.With("at-uri", aturi) 789 869 l.Info("wrote record to PDS") 790 870 791 - l.Info("adding to knot") 792 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 871 + tx, err := rp.db.BeginTx(r.Context(), nil) 793 872 if err != nil { 794 - fail("Failed to add to knot.", err) 873 + fail("Failed to add collaborator.", err) 795 874 return 796 875 } 797 876 798 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 799 - if err != nil { 800 - fail("Failed to add to knot.", err) 801 - return 802 - } 877 + rollback := func() { 878 + err1 := tx.Rollback() 879 + err2 := rp.enforcer.E.LoadPolicy() 880 + err3 := rollbackRecord(context.Background(), aturi, client) 803 881 804 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 805 - if err != nil { 806 - fail("Knot was unreachable.", err) 807 - return 808 - } 882 + // ignore txn complete errors, this is okay 883 + if errors.Is(err1, sql.ErrTxDone) { 884 + err1 = nil 885 + } 809 886 810 - if ksResp.StatusCode != http.StatusNoContent { 811 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 812 - return 813 - } 814 - 815 - tx, err := rp.db.BeginTx(r.Context(), nil) 816 - if err != nil { 817 - fail("Failed to add collaborator.", err) 818 - return 819 - } 820 - defer func() { 821 - tx.Rollback() 822 - err = rp.enforcer.E.LoadPolicy() 823 - if err != nil { 824 - fail("Failed to add collaborator.", err) 887 + if errs := errors.Join(err1, err2, err3); errs != nil { 888 + l.Error("failed to rollback changes", "errs", errs) 889 + return 825 890 } 826 - }() 891 + } 892 + defer rollback() 827 893 828 894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 829 895 if err != nil { ··· 835 901 Did: syntax.DID(currentUser.Did), 836 902 Rkey: rkey, 837 903 SubjectDid: collaboratorIdent.DID, 838 - RepoAt: f.RepoAt, 904 + RepoAt: f.RepoAt(), 839 905 Created: createdAt, 840 906 }) 841 907 if err != nil { ··· 855 921 return 856 922 } 857 923 924 + // clear aturi to when everything is successful 925 + aturi = "" 926 + 858 927 rp.pages.HxRefresh(w) 859 928 } 860 929 861 930 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 862 931 user := rp.oauth.GetUser(r) 863 932 933 + noticeId := "operation-error" 864 934 f, err := rp.repoResolver.Resolve(r) 865 935 if err != nil { 866 936 log.Println("failed to get repo and knot", err) ··· 873 943 log.Println("failed to get authorized client", err) 874 944 return 875 945 } 876 - repoRkey := f.RepoAt.RecordKey().String() 877 946 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 878 947 Collection: tangled.RepoNSID, 879 948 Repo: user.Did, 880 - Rkey: repoRkey, 949 + Rkey: f.Rkey, 881 950 }) 882 951 if err != nil { 883 952 log.Printf("failed to delete record: %s", err) 884 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 885 - return 886 - } 887 - log.Println("removed repo record ", f.RepoAt.String()) 888 - 889 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 890 - if err != nil { 891 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 953 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 892 954 return 893 955 } 956 + log.Println("removed repo record ", f.RepoAt().String()) 894 957 895 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 958 + client, err := rp.oauth.ServiceClient( 959 + r, 960 + oauth.WithService(f.Knot), 961 + oauth.WithLxm(tangled.RepoDeleteNSID), 962 + oauth.WithDev(rp.config.Core.Dev), 963 + ) 896 964 if err != nil { 897 - log.Println("failed to create client to ", f.Knot) 965 + log.Println("failed to connect to knot server:", err) 898 966 return 899 967 } 900 968 901 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 902 - if err != nil { 903 - log.Printf("failed to make request to %s: %s", f.Knot, err) 969 + err = tangled.RepoDelete( 970 + r.Context(), 971 + client, 972 + &tangled.RepoDelete_Input{ 973 + Did: f.OwnerDid(), 974 + Name: f.Name, 975 + Rkey: f.Rkey, 976 + }, 977 + ) 978 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 979 + rp.pages.Notice(w, noticeId, err.Error()) 904 980 return 905 981 } 906 - 907 - if ksResp.StatusCode != http.StatusNoContent { 908 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 909 - } else { 910 - log.Println("removed repo from knot ", f.Knot) 911 - } 982 + log.Println("deleted repo from knot") 912 983 913 984 tx, err := rp.db.BeginTx(r.Context(), nil) 914 985 if err != nil { ··· 927 998 // remove collaborator RBAC 928 999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 929 1000 if err != nil { 930 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1001 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 931 1002 return 932 1003 } 933 1004 for _, c := range repoCollaborators { ··· 939 1010 // remove repo RBAC 940 1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 941 1012 if err != nil { 942 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1013 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 943 1014 return 944 1015 } 945 1016 946 1017 // remove repo from db 947 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 1018 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 948 1019 if err != nil { 949 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1020 + rp.pages.Notice(w, noticeId, "Failed to update appview") 950 1021 return 951 1022 } 952 1023 log.Println("removed repo from db") ··· 975 1046 return 976 1047 } 977 1048 1049 + noticeId := "operation-error" 978 1050 branch := r.FormValue("branch") 979 1051 if branch == "" { 980 1052 http.Error(w, "malformed form", http.StatusBadRequest) 981 1053 return 982 1054 } 983 1055 984 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 985 - if err != nil { 986 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 987 - return 988 - } 989 - 990 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1056 + client, err := rp.oauth.ServiceClient( 1057 + r, 1058 + oauth.WithService(f.Knot), 1059 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1060 + oauth.WithDev(rp.config.Core.Dev), 1061 + ) 991 1062 if err != nil { 992 - log.Println("failed to create client to ", f.Knot) 1063 + log.Println("failed to connect to knot server:", err) 1064 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 993 1065 return 994 1066 } 995 1067 996 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 997 - if err != nil { 998 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1068 + xe := tangled.RepoSetDefaultBranch( 1069 + r.Context(), 1070 + client, 1071 + &tangled.RepoSetDefaultBranch_Input{ 1072 + Repo: f.RepoAt().String(), 1073 + DefaultBranch: branch, 1074 + }, 1075 + ) 1076 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1077 + log.Println("xrpc failed", "err", xe) 1078 + rp.pages.Notice(w, noticeId, err.Error()) 999 1079 return 1000 1080 } 1001 1081 1002 - if ksResp.StatusCode != http.StatusNoContent { 1003 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1004 - return 1005 - } 1006 - 1007 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1082 + rp.pages.HxRefresh(w) 1008 1083 } 1009 1084 1010 1085 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1033 1108 r, 1034 1109 oauth.WithService(f.Spindle), 1035 1110 oauth.WithLxm(lxm), 1111 + oauth.WithExp(60), 1036 1112 oauth.WithDev(rp.config.Core.Dev), 1037 1113 ) 1038 1114 if err != nil { ··· 1060 1136 r.Context(), 1061 1137 spindleClient, 1062 1138 &tangled.RepoAddSecret_Input{ 1063 - Repo: f.RepoAt.String(), 1139 + Repo: f.RepoAt().String(), 1064 1140 Key: key, 1065 1141 Value: value, 1066 1142 }, ··· 1078 1154 r.Context(), 1079 1155 spindleClient, 1080 1156 &tangled.RepoRemoveSecret_Input{ 1081 - Repo: f.RepoAt.String(), 1157 + Repo: f.RepoAt().String(), 1082 1158 Key: key, 1083 1159 }, 1084 1160 ) ··· 1119 1195 case "pipelines": 1120 1196 rp.pipelineSettings(w, r) 1121 1197 } 1122 - 1123 - // user := rp.oauth.GetUser(r) 1124 - // repoCollaborators, err := f.Collaborators(r.Context()) 1125 - // if err != nil { 1126 - // log.Println("failed to get collaborators", err) 1127 - // } 1128 - 1129 - // isCollaboratorInviteAllowed := false 1130 - // if user != nil { 1131 - // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1132 - // if err == nil && ok { 1133 - // isCollaboratorInviteAllowed = true 1134 - // } 1135 - // } 1136 - 1137 - // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1138 - // if err != nil { 1139 - // log.Println("failed to create unsigned client", err) 1140 - // return 1141 - // } 1142 - 1143 - // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1144 - // if err != nil { 1145 - // log.Println("failed to reach knotserver", err) 1146 - // return 1147 - // } 1148 - 1149 - // // all spindles that this user is a member of 1150 - // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1151 - // if err != nil { 1152 - // log.Println("failed to fetch spindles", err) 1153 - // return 1154 - // } 1155 - 1156 - // var secrets []*tangled.RepoListSecrets_Secret 1157 - // if f.Spindle != "" { 1158 - // if spindleClient, err := rp.oauth.ServiceClient( 1159 - // r, 1160 - // oauth.WithService(f.Spindle), 1161 - // oauth.WithLxm(tangled.RepoListSecretsNSID), 1162 - // oauth.WithDev(rp.config.Core.Dev), 1163 - // ); err != nil { 1164 - // log.Println("failed to create spindle client", err) 1165 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1166 - // log.Println("failed to fetch secrets", err) 1167 - // } else { 1168 - // secrets = resp.Secrets 1169 - // } 1170 - // } 1171 - 1172 - // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1173 - // LoggedInUser: user, 1174 - // RepoInfo: f.RepoInfo(user), 1175 - // Collaborators: repoCollaborators, 1176 - // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1177 - // Branches: result.Branches, 1178 - // Spindles: spindles, 1179 - // CurrentSpindle: f.Spindle, 1180 - // Secrets: secrets, 1181 - // }) 1182 1198 } 1183 1199 1184 1200 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1191 1207 return 1192 1208 } 1193 1209 1194 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1210 + result, err := us.Branches(f.OwnerDid(), f.Name) 1195 1211 if err != nil { 1212 + rp.pages.Error503(w) 1196 1213 log.Println("failed to reach knotserver", err) 1197 1214 return 1198 1215 } ··· 1241 1258 r, 1242 1259 oauth.WithService(f.Spindle), 1243 1260 oauth.WithLxm(tangled.RepoListSecretsNSID), 1261 + oauth.WithExp(60), 1244 1262 oauth.WithDev(rp.config.Core.Dev), 1245 1263 ); err != nil { 1246 1264 log.Println("failed to create spindle client", err) 1247 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1265 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1248 1266 log.Println("failed to fetch secrets", err) 1249 1267 } else { 1250 1268 secrets = resp.Secrets ··· 1285 1303 } 1286 1304 1287 1305 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1306 + ref := chi.URLParam(r, "ref") 1307 + 1288 1308 user := rp.oauth.GetUser(r) 1289 1309 f, err := rp.repoResolver.Resolve(r) 1290 1310 if err != nil { ··· 1294 1314 1295 1315 switch r.Method { 1296 1316 case http.MethodPost: 1297 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1317 + client, err := rp.oauth.ServiceClient( 1318 + r, 1319 + oauth.WithService(f.Knot), 1320 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1321 + oauth.WithDev(rp.config.Core.Dev), 1322 + ) 1298 1323 if err != nil { 1299 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1324 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1300 1325 return 1301 1326 } 1302 1327 1303 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1304 - if err != nil { 1305 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1328 + repoInfo := f.RepoInfo(user) 1329 + if repoInfo.Source == nil { 1330 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1306 1331 return 1307 1332 } 1308 1333 1309 - var uri string 1310 - if rp.config.Core.Dev { 1311 - uri = "http" 1312 - } else { 1313 - uri = "https" 1314 - } 1315 - forkName := fmt.Sprintf("%s", f.RepoName) 1316 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1317 - 1318 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1319 - if err != nil { 1320 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1334 + err = tangled.RepoForkSync( 1335 + r.Context(), 1336 + client, 1337 + &tangled.RepoForkSync_Input{ 1338 + Did: user.Did, 1339 + Name: f.Name, 1340 + Source: repoInfo.Source.RepoAt().String(), 1341 + Branch: ref, 1342 + }, 1343 + ) 1344 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1345 + rp.pages.Notice(w, "repo", err.Error()) 1321 1346 return 1322 1347 } 1323 1348 ··· 1350 1375 }) 1351 1376 1352 1377 case http.MethodPost: 1378 + l := rp.logger.With("handler", "ForkRepo") 1353 1379 1354 - knot := r.FormValue("knot") 1355 - if knot == "" { 1380 + targetKnot := r.FormValue("knot") 1381 + if targetKnot == "" { 1356 1382 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1357 1383 return 1358 1384 } 1385 + l = l.With("targetKnot", targetKnot) 1359 1386 1360 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1387 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1361 1388 if err != nil || !ok { 1362 1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1363 1390 return 1364 1391 } 1365 1392 1366 - forkName := fmt.Sprintf("%s", f.RepoName) 1367 - 1393 + // choose a name for a fork 1394 + forkName := f.Name 1368 1395 // this check is *only* to see if the forked repo name already exists 1369 1396 // in the user's account. 1370 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1397 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1371 1398 if err != nil { 1372 1399 if errors.Is(err, sql.ErrNoRows) { 1373 1400 // no existing repo with this name found, we can use the name as is ··· 1380 1407 // repo with this name already exists, append random string 1381 1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1382 1409 } 1383 - secret, err := db.GetRegistrationKey(rp.db, knot) 1384 - if err != nil { 1385 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1386 - return 1387 - } 1410 + l = l.With("forkName", forkName) 1388 1411 1389 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1390 - if err != nil { 1391 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1392 - return 1393 - } 1394 - 1395 - var uri string 1412 + uri := "https" 1396 1413 if rp.config.Core.Dev { 1397 1414 uri = "http" 1398 - } else { 1399 - uri = "https" 1400 1415 } 1401 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1402 - sourceAt := f.RepoAt.String() 1403 1416 1417 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1418 + l = l.With("cloneUrl", forkSourceUrl) 1419 + 1420 + sourceAt := f.RepoAt().String() 1421 + 1422 + // create an atproto record for this fork 1404 1423 rkey := tid.TID() 1405 1424 repo := &db.Repo{ 1406 1425 Did: user.Did, 1407 1426 Name: forkName, 1408 - Knot: knot, 1427 + Knot: targetKnot, 1409 1428 Rkey: rkey, 1410 1429 Source: sourceAt, 1411 1430 } 1412 1431 1413 - tx, err := rp.db.BeginTx(r.Context(), nil) 1414 - if err != nil { 1415 - log.Println(err) 1416 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1417 - return 1418 - } 1419 - defer func() { 1420 - tx.Rollback() 1421 - err = rp.enforcer.E.LoadPolicy() 1422 - if err != nil { 1423 - log.Println("failed to rollback policies") 1424 - } 1425 - }() 1426 - 1427 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1428 - if err != nil { 1429 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1430 - return 1431 - } 1432 - 1433 - switch resp.StatusCode { 1434 - case http.StatusConflict: 1435 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1436 - return 1437 - case http.StatusInternalServerError: 1438 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1439 - case http.StatusNoContent: 1440 - // continue 1441 - } 1442 - 1443 1432 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1444 1433 if err != nil { 1445 - log.Println("failed to get authorized client", err) 1446 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1434 + l.Error("failed to create xrpcclient", "err", err) 1435 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1447 1436 return 1448 1437 } 1449 1438 ··· 1462 1451 }}, 1463 1452 }) 1464 1453 if err != nil { 1465 - log.Printf("failed to create record: %s", err) 1454 + l.Error("failed to write to PDS", "err", err) 1466 1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1467 1456 return 1468 1457 } 1469 - log.Println("created repo record: ", atresp.Uri) 1458 + 1459 + aturi := atresp.Uri 1460 + l = l.With("aturi", aturi) 1461 + l.Info("wrote to PDS") 1470 1462 1471 - repo.AtUri = atresp.Uri 1463 + tx, err := rp.db.BeginTx(r.Context(), nil) 1464 + if err != nil { 1465 + l.Info("txn failed", "err", err) 1466 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1467 + return 1468 + } 1469 + 1470 + // The rollback function reverts a few things on failure: 1471 + // - the pending txn 1472 + // - the ACLs 1473 + // - the atproto record created 1474 + rollback := func() { 1475 + err1 := tx.Rollback() 1476 + err2 := rp.enforcer.E.LoadPolicy() 1477 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1478 + 1479 + // ignore txn complete errors, this is okay 1480 + if errors.Is(err1, sql.ErrTxDone) { 1481 + err1 = nil 1482 + } 1483 + 1484 + if errs := errors.Join(err1, err2, err3); errs != nil { 1485 + l.Error("failed to rollback changes", "errs", errs) 1486 + return 1487 + } 1488 + } 1489 + defer rollback() 1490 + 1491 + client, err := rp.oauth.ServiceClient( 1492 + r, 1493 + oauth.WithService(targetKnot), 1494 + oauth.WithLxm(tangled.RepoCreateNSID), 1495 + oauth.WithDev(rp.config.Core.Dev), 1496 + ) 1497 + if err != nil { 1498 + l.Error("could not create service client", "err", err) 1499 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1500 + return 1501 + } 1502 + 1503 + err = tangled.RepoCreate( 1504 + r.Context(), 1505 + client, 1506 + &tangled.RepoCreate_Input{ 1507 + Rkey: rkey, 1508 + Source: &forkSourceUrl, 1509 + }, 1510 + ) 1511 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1512 + rp.pages.Notice(w, "repo", err.Error()) 1513 + return 1514 + } 1515 + 1472 1516 err = db.AddRepo(tx, repo) 1473 1517 if err != nil { 1474 1518 log.Println(err) ··· 1478 1522 1479 1523 // acls 1480 1524 p, _ := securejoin.SecureJoin(user.Did, forkName) 1481 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1525 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1482 1526 if err != nil { 1483 1527 log.Println(err) 1484 1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1499 1543 return 1500 1544 } 1501 1545 1546 + // reset the ATURI because the transaction completed successfully 1547 + aturi = "" 1548 + 1549 + rp.notifier.NewRepo(r.Context(), repo) 1502 1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1503 - return 1504 1551 } 1505 1552 } 1506 1553 1554 + // this is used to rollback changes made to the PDS 1555 + // 1556 + // it is a no-op if the provided ATURI is empty 1557 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1558 + if aturi == "" { 1559 + return nil 1560 + } 1561 + 1562 + parsed := syntax.ATURI(aturi) 1563 + 1564 + collection := parsed.Collection().String() 1565 + repo := parsed.Authority().String() 1566 + rkey := parsed.RecordKey().String() 1567 + 1568 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1569 + Collection: collection, 1570 + Repo: repo, 1571 + Rkey: rkey, 1572 + }) 1573 + return err 1574 + } 1575 + 1507 1576 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1508 1577 user := rp.oauth.GetUser(r) 1509 1578 f, err := rp.repoResolver.Resolve(r) ··· 1519 1588 return 1520 1589 } 1521 1590 1522 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1591 + result, err := us.Branches(f.OwnerDid(), f.Name) 1523 1592 if err != nil { 1524 1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1525 1594 log.Println("failed to reach knotserver", err) ··· 1549 1618 head = queryHead 1550 1619 } 1551 1620 1552 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1621 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1553 1622 if err != nil { 1554 1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1555 1624 log.Println("failed to reach knotserver", err) ··· 1611 1680 return 1612 1681 } 1613 1682 1614 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1683 + branches, err := us.Branches(f.OwnerDid(), f.Name) 1615 1684 if err != nil { 1616 1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1617 1686 log.Println("failed to reach knotserver", err) 1618 1687 return 1619 1688 } 1620 1689 1621 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1690 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1622 1691 if err != nil { 1623 1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 1693 log.Println("failed to reach knotserver", err) 1625 1694 return 1626 1695 } 1627 1696 1628 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1697 + formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1629 1698 if err != nil { 1630 1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1631 1700 log.Println("failed to compare", err)
+5
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 13 14 r.Get("/commits/{ref}", rp.RepoLog) 14 15 r.Route("/tree/{ref}", func(r chi.Router) { 15 16 r.Get("/", rp.RepoIndex) ··· 37 38 }) 38 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 40 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 + 42 + // intentionally doesn't use /* as this isn't 43 + // a file path 44 + r.Get("/archive/{ref}", rp.DownloadArchive) 40 45 41 46 r.Route("/fork", func(r chi.Router) { 42 47 r.Use(middleware.AuthMiddleware(rp.oauth))
+37 -104
appview/reporesolver/resolver.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 - "net/url" 11 10 "path" 11 + "regexp" 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/go-chi/chi/v5" 18 17 "tangled.sh/tangled.sh/core/appview/config" ··· 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 22 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 23 "tangled.sh/tangled.sh/core/rbac" 26 24 ) 27 25 28 26 type ResolvedRepo struct { 29 - Knot string 30 - OwnerId identity.Identity 31 - RepoName string 32 - RepoAt syntax.ATURI 33 - Description string 34 - Spindle string 35 - CreatedAt string 36 - Ref string 37 - CurrentDir string 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 38 31 39 32 rr *RepoResolver 40 33 } ··· 51 44 } 52 45 53 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 56 48 if !ok { 57 - log.Println("malformed middleware") 49 + log.Println("malformed middleware: `repo` not exist in context") 58 50 return nil, fmt.Errorf("malformed middleware") 59 51 } 60 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 55 return nil, fmt.Errorf("malformed middleware") 64 56 } 65 57 66 - repoAt, ok := r.Context().Value("repoAt").(string) 67 - if !ok { 68 - log.Println("malformed middleware") 69 - return nil, fmt.Errorf("malformed middleware") 70 - } 71 - 72 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 - if err != nil { 74 - log.Println("malformed repo at-uri") 75 - return nil, fmt.Errorf("malformed middleware") 76 - } 77 - 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 78 59 ref := chi.URLParam(r, "ref") 79 60 80 - if ref == "" { 81 - us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 82 - if err != nil { 83 - return nil, err 84 - } 85 - 86 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 87 - if err != nil { 88 - return nil, err 89 - } 90 - 91 - ref = defaultBranch.Branch 92 - } 93 - 94 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 - 96 - // pass through values from the middleware 97 - description, ok := r.Context().Value("repoDescription").(string) 98 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 - spindle, ok := r.Context().Value("repoSpindle").(string) 100 - 101 61 return &ResolvedRepo{ 102 - Knot: knot, 103 - OwnerId: id, 104 - RepoName: repoName, 105 - RepoAt: parsedRepoAt, 106 - Description: description, 107 - CreatedAt: addedAt, 108 - Ref: ref, 109 - CurrentDir: currentDir, 110 - Spindle: spindle, 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 111 66 112 67 rr: rr, 113 68 }, nil ··· 126 81 127 82 var p string 128 83 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 130 85 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 132 87 } 133 88 134 - return p 135 - } 136 - 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 89 return p 140 90 } 141 91 ··· 187 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 188 138 // package. we should refactor this or get rid of RepoInfo entirely. 189 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 190 141 isStarred := false 191 142 if user != nil { 192 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 193 144 } 194 145 195 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 196 147 if err != nil { 197 - log.Println("failed to get star count for ", f.RepoAt) 148 + log.Println("failed to get star count for ", repoAt) 198 149 } 199 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 200 151 if err != nil { 201 - log.Println("failed to get issue count for ", f.RepoAt) 152 + log.Println("failed to get issue count for ", repoAt) 202 153 } 203 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 204 155 if err != nil { 205 - log.Println("failed to get issue count for ", f.RepoAt) 156 + log.Println("failed to get issue count for ", repoAt) 206 157 } 207 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 208 159 if errors.Is(err, sql.ErrNoRows) { 209 160 source = "" 210 161 } else if err != nil { 211 - log.Println("failed to get repo source for ", f.RepoAt, err) 162 + log.Println("failed to get repo source for ", repoAt, err) 212 163 } 213 164 214 165 var sourceRepo *db.Repo ··· 228 179 } 229 180 230 181 knot := f.Knot 231 - var disableFork bool 232 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 233 - if err != nil { 234 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 235 - } else { 236 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 237 - if err != nil { 238 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 239 - } 240 - 241 - if len(result.Branches) == 0 { 242 - disableFork = true 243 - } 244 - } 245 182 246 183 repoInfo := repoinfo.RepoInfo{ 247 184 OwnerDid: f.OwnerDid(), 248 185 OwnerHandle: f.OwnerHandle(), 249 - Name: f.RepoName, 250 - RepoAt: f.RepoAt, 186 + Name: f.Name, 187 + RepoAt: repoAt, 251 188 Description: f.Description, 252 - Ref: f.Ref, 253 189 IsStarred: isStarred, 254 190 Knot: knot, 255 191 Spindle: f.Spindle, ··· 259 195 IssueCount: issueCount, 260 196 PullCount: pullCount, 261 197 }, 262 - DisableFork: disableFork, 263 - CurrentDir: f.CurrentDir, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 264 200 } 265 201 266 202 if sourceRepo != nil { ··· 284 220 // after the ref. for example: 285 221 // 286 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 287 - func extractPathAfterRef(fullPath, ref string) string { 223 + func extractPathAfterRef(fullPath string) string { 288 224 fullPath = strings.TrimPrefix(fullPath, "/") 289 225 290 - ref = url.PathEscape(ref) 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 291 230 292 - prefixes := []string{ 293 - fmt.Sprintf("blob/%s/", ref), 294 - fmt.Sprintf("tree/%s/", ref), 295 - fmt.Sprintf("raw/%s/", ref), 296 - } 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 297 233 298 - for _, prefix := range prefixes { 299 - idx := strings.Index(fullPath, prefix) 300 - if idx != -1 { 301 - return fullPath[idx+len(prefix):] 302 - } 234 + if len(matches) > 1 { 235 + return matches[1] 303 236 } 304 237 305 238 return ""
+164
appview/serververify/verify.go
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + ) 15 + 16 + var ( 17 + FetchError = errors.New("failed to fetch owner") 18 + ) 19 + 20 + // fetchOwner fetches the owner DID from a server's /owner endpoint 21 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 + scheme := "https" 23 + if dev { 24 + scheme = "http" 25 + } 26 + 27 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 + req, err := http.NewRequest("GET", url, nil) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + client := &http.Client{ 34 + Timeout: 1 * time.Second, 35 + } 36 + 37 + resp, err := client.Do(req.WithContext(ctx)) 38 + if err != nil || resp.StatusCode != 200 { 39 + return "", fmt.Errorf("failed to fetch /owner") 40 + } 41 + 42 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 + if err != nil { 44 + return "", fmt.Errorf("failed to read /owner response: %w", err) 45 + } 46 + 47 + did := strings.TrimSpace(string(body)) 48 + if did == "" { 49 + return "", fmt.Errorf("empty DID in /owner response") 50 + } 51 + 52 + return did, nil 53 + } 54 + 55 + type OwnerMismatch struct { 56 + expected string 57 + observed string 58 + } 59 + 60 + func (e *OwnerMismatch) Error() string { 61 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 + } 63 + 64 + // RunVerification verifies that the server at the given domain has the expected owner 65 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 + observedOwner, err := fetchOwner(ctx, domain, dev) 67 + if err != nil { 68 + return fmt.Errorf("%w: %w", FetchError, err) 69 + } 70 + 71 + if observedOwner != expectedOwner { 72 + return &OwnerMismatch{ 73 + expected: expectedOwner, 74 + observed: observedOwner, 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 82 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 + tx, err := d.Begin() 84 + if err != nil { 85 + return 0, fmt.Errorf("failed to create txn: %w", err) 86 + } 87 + defer func() { 88 + tx.Rollback() 89 + e.E.LoadPolicy() 90 + }() 91 + 92 + // mark this spindle as verified in the db 93 + rowId, err := db.VerifySpindle( 94 + tx, 95 + db.FilterEq("owner", owner), 96 + db.FilterEq("instance", instance), 97 + ) 98 + if err != nil { 99 + return 0, fmt.Errorf("failed to write to DB: %w", err) 100 + } 101 + 102 + err = e.AddSpindleOwner(instance, owner) 103 + if err != nil { 104 + return 0, fmt.Errorf("failed to update ACL: %w", err) 105 + } 106 + 107 + err = tx.Commit() 108 + if err != nil { 109 + return 0, fmt.Errorf("failed to commit txn: %w", err) 110 + } 111 + 112 + err = e.E.SavePolicy() 113 + if err != nil { 114 + return 0, fmt.Errorf("failed to update ACL: %w", err) 115 + } 116 + 117 + return rowId, nil 118 + } 119 + 120 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 121 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 122 + tx, err := d.BeginTx(context.Background(), nil) 123 + if err != nil { 124 + return fmt.Errorf("failed to start tx: %w", err) 125 + } 126 + defer func() { 127 + tx.Rollback() 128 + e.E.LoadPolicy() 129 + }() 130 + 131 + // mark as registered 132 + err = db.MarkRegistered( 133 + tx, 134 + db.FilterEq("did", owner), 135 + db.FilterEq("domain", domain), 136 + ) 137 + if err != nil { 138 + return fmt.Errorf("failed to register domain: %w", err) 139 + } 140 + 141 + // add basic acls for this domain 142 + err = e.AddKnot(domain) 143 + if err != nil { 144 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 145 + } 146 + 147 + // add this did as owner of this domain 148 + err = e.AddKnotOwner(domain, owner) 149 + if err != nil { 150 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 151 + } 152 + 153 + err = tx.Commit() 154 + if err != nil { 155 + return fmt.Errorf("failed to commit changes: %w", err) 156 + } 157 + 158 + err = e.E.SavePolicy() 159 + if err != nil { 160 + return fmt.Errorf("failed to update ACLs: %w", err) 161 + } 162 + 163 + return nil 164 + }
+44 -9
appview/settings/settings.go
··· 33 33 Config *config.Config 34 34 } 35 35 36 + type tab = map[string]any 37 + 38 + var ( 39 + settingsTabs []tab = []tab{ 40 + {"Name": "profile", "Icon": "user"}, 41 + {"Name": "keys", "Icon": "key"}, 42 + {"Name": "emails", "Icon": "mail"}, 43 + } 44 + ) 45 + 36 46 func (s *Settings) Router() http.Handler { 37 47 r := chi.NewRouter() 38 48 39 49 r.Use(middleware.AuthMiddleware(s.OAuth)) 40 50 41 - r.Get("/", s.settings) 51 + // settings pages 52 + r.Get("/", s.profileSettings) 53 + r.Get("/profile", s.profileSettings) 42 54 43 55 r.Route("/keys", func(r chi.Router) { 56 + r.Get("/", s.keysSettings) 44 57 r.Put("/", s.keys) 45 58 r.Delete("/", s.keys) 46 59 }) 47 60 48 61 r.Route("/emails", func(r chi.Router) { 62 + r.Get("/", s.emailsSettings) 49 63 r.Put("/", s.emails) 50 64 r.Delete("/", s.emails) 51 65 r.Get("/verify", s.emailsVerify) ··· 56 70 return r 57 71 } 58 72 59 - func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 73 + func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 + user := s.OAuth.GetUser(r) 75 + 76 + s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 + LoggedInUser: user, 78 + Tabs: settingsTabs, 79 + Tab: "profile", 80 + }) 81 + } 82 + 83 + func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 60 84 user := s.OAuth.GetUser(r) 61 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 62 86 if err != nil { 63 87 log.Println(err) 64 88 } 65 89 90 + s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 + LoggedInUser: user, 92 + PubKeys: pubKeys, 93 + Tabs: settingsTabs, 94 + Tab: "keys", 95 + }) 96 + } 97 + 98 + func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 + user := s.OAuth.GetUser(r) 66 100 emails, err := db.GetAllEmails(s.Db, user.Did) 67 101 if err != nil { 68 102 log.Println(err) 69 103 } 70 104 71 - s.Pages.Settings(w, pages.SettingsParams{ 105 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 72 106 LoggedInUser: user, 73 - PubKeys: pubKeys, 74 107 Emails: emails, 108 + Tabs: settingsTabs, 109 + Tab: "emails", 75 110 }) 76 111 } 77 112 ··· 201 236 return 202 237 } 203 238 204 - s.Pages.HxLocation(w, "/settings") 239 + s.Pages.HxLocation(w, "/settings/emails") 205 240 return 206 241 } 207 242 } ··· 244 279 return 245 280 } 246 281 247 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 282 + http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 248 283 } 249 284 250 285 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 339 374 return 340 375 } 341 376 342 - s.Pages.HxLocation(w, "/settings") 377 + s.Pages.HxLocation(w, "/settings/emails") 343 378 } 344 379 345 380 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 410 445 return 411 446 } 412 447 413 - s.Pages.HxLocation(w, "/settings") 448 + s.Pages.HxLocation(w, "/settings/keys") 414 449 return 415 450 416 451 case http.MethodDelete: ··· 455 490 } 456 491 log.Println("deleted successfully") 457 492 458 - s.Pages.HxLocation(w, "/settings") 493 + s.Pages.HxLocation(w, "/settings/keys") 459 494 return 460 495 } 461 496 }
+8 -21
appview/spindles/spindles.go
··· 15 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 17 "tangled.sh/tangled.sh/core/appview/pages" 18 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 19 "tangled.sh/tangled.sh/core/idresolver" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 "tangled.sh/tangled.sh/core/tid" ··· 113 113 return 114 114 } 115 115 116 - identsToResolve := make([]string, len(members)) 117 - copy(identsToResolve, members) 118 - resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 119 - didHandleMap := make(map[string]string) 120 - for _, identity := range resolvedIds { 121 - if !identity.Handle.IsInvalidHandle() { 122 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 123 - } else { 124 - didHandleMap[identity.DID.String()] = identity.DID.String() 125 - } 126 - } 127 - 128 116 // organize repos by did 129 117 repoMap := make(map[string][]db.Repo) 130 118 for _, r := range repos { ··· 136 124 Spindle: spindle, 137 125 Members: members, 138 126 Repos: repoMap, 139 - DidHandleMap: didHandleMap, 140 127 }) 141 128 } 142 129 ··· 240 227 } 241 228 242 229 // begin verification 243 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 230 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 231 if err != nil { 245 232 l.Error("verification failed", "err", err) 246 233 s.Pages.HxRefresh(w) 247 234 return 248 235 } 249 236 250 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 237 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 251 238 if err != nil { 252 239 l.Error("failed to mark verified", "err", err) 253 240 s.Pages.HxRefresh(w) ··· 413 400 } 414 401 415 402 // begin verification 416 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 403 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 417 404 if err != nil { 418 405 l.Error("verification failed", "err", err) 419 406 420 - if errors.Is(err, verify.FetchError) { 421 - s.Pages.Notice(w, noticeId, err.Error()) 407 + if errors.Is(err, serververify.FetchError) { 408 + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 422 409 return 423 410 } 424 411 425 - if e, ok := err.(*verify.OwnerMismatch); ok { 412 + if e, ok := err.(*serververify.OwnerMismatch); ok { 426 413 s.Pages.Notice(w, noticeId, e.Error()) 427 414 return 428 415 } ··· 431 418 return 432 419 } 433 420 434 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 421 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 435 422 if err != nil { 436 423 l.Error("failed to mark verified", "err", err) 437 424 s.Pages.Notice(w, noticeId, err.Error())
-118
appview/spindleverify/verify.go
··· 1 - package spindleverify 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 - 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - ) 15 - 16 - var ( 17 - FetchError = errors.New("failed to fetch owner") 18 - ) 19 - 20 - // TODO: move this to "spindleclient" or similar 21 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 - scheme := "https" 23 - if dev { 24 - scheme = "http" 25 - } 26 - 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 - } 41 - 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 - } 46 - 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 - } 54 - 55 - type OwnerMismatch struct { 56 - expected string 57 - observed string 58 - } 59 - 60 - func (e *OwnerMismatch) Error() string { 61 - return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 - } 63 - 64 - func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 - // begin verification 66 - observedOwner, err := fetchOwner(ctx, instance, dev) 67 - if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 - } 70 - 71 - if observedOwner != expectedOwner { 72 - return &OwnerMismatch{ 73 - expected: expectedOwner, 74 - observed: observedOwner, 75 - } 76 - } 77 - 78 - return nil 79 - } 80 - 81 - // mark this spindle as verified in the DB and add this user as its owner 82 - func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 - tx, err := d.Begin() 84 - if err != nil { 85 - return 0, fmt.Errorf("failed to create txn: %w", err) 86 - } 87 - defer func() { 88 - tx.Rollback() 89 - e.E.LoadPolicy() 90 - }() 91 - 92 - // mark this spindle as verified in the db 93 - rowId, err := db.VerifySpindle( 94 - tx, 95 - db.FilterEq("owner", owner), 96 - db.FilterEq("instance", instance), 97 - ) 98 - if err != nil { 99 - return 0, fmt.Errorf("failed to write to DB: %w", err) 100 - } 101 - 102 - err = e.AddSpindleOwner(instance, owner) 103 - if err != nil { 104 - return 0, fmt.Errorf("failed to update ACL: %w", err) 105 - } 106 - 107 - err = tx.Commit() 108 - if err != nil { 109 - return 0, fmt.Errorf("failed to commit txn: %w", err) 110 - } 111 - 112 - err = e.E.SavePolicy() 113 - if err != nil { 114 - return 0, fmt.Errorf("failed to update ACL: %w", err) 115 - } 116 - 117 - return rowId, nil 118 - }
+9 -12
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "maps" 6 7 "net/http" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/identity" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 10 12 ) 11 13 12 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 15 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 + repo := r.Context().Value("repo").(*db.Repo) 16 17 17 18 scheme := "https" 18 19 if s.config.Core.Dev { 19 20 scheme = "http" 20 21 } 21 22 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 23 24 s.proxyRequest(w, r, targetURL) 24 25 25 26 } ··· 30 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 32 return 32 33 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 34 + repo := r.Context().Value("repo").(*db.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { 38 38 scheme = "http" 39 39 } 40 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 42 s.proxyRequest(w, r, targetURL) 43 43 } 44 44 ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 51 + repo := r.Context().Value("repo").(*db.Repo) 53 52 54 53 scheme := "https" 55 54 if s.config.Core.Dev { 56 55 scheme = "http" 57 56 } 58 57 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 60 59 s.proxyRequest(w, r, targetURL) 61 60 } 62 61 ··· 85 84 defer resp.Body.Close() 86 85 87 86 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 87 + maps.Copy(w.Header(), resp.Header) 91 88 92 89 // Set response status code 93 90 w.WriteHeader(resp.StatusCode)
+5 -2
appview/state/knotstream.go
··· 24 24 ) 25 25 26 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetCompletedRegistrations(d) 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 28 31 if err != nil { 29 32 return nil, err 30 33 } 31 34 32 35 srcs := make(map[ec.Source]struct{}) 33 36 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 37 + s := ec.NewKnotSource(k.Domain) 35 38 srcs[s] = struct{}{} 36 39 } 37 40
+419 -112
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "log" 6 7 "net/http" ··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 "github.com/go-chi/chi/v5" 17 + "github.com/gorilla/feeds" 16 18 "tangled.sh/tangled.sh/core/api/tangled" 17 19 "tangled.sh/tangled.sh/core/appview/db" 20 + // "tangled.sh/tangled.sh/core/appview/oauth" 18 21 "tangled.sh/tangled.sh/core/appview/pages" 19 22 ) 20 23 21 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 22 25 tabVal := r.URL.Query().Get("tab") 23 26 switch tabVal { 24 - case "": 25 - s.profilePage(w, r) 26 27 case "repos": 27 28 s.reposPage(w, r) 29 + case "followers": 30 + s.followersPage(w, r) 31 + case "following": 32 + s.followingPage(w, r) 33 + case "starred": 34 + s.starredPage(w, r) 35 + case "strings": 36 + s.stringsPage(w, r) 37 + default: 38 + s.profileOverview(w, r) 28 39 } 29 40 } 30 41 31 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 42 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 32 43 didOrHandle := chi.URLParam(r, "user") 33 44 if didOrHandle == "" { 34 - http.Error(w, "Bad request", http.StatusBadRequest) 35 - return 45 + return nil, fmt.Errorf("empty DID or handle") 36 46 } 37 47 38 48 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 39 49 if !ok { 40 - s.pages.Error404(w) 41 - return 50 + return nil, fmt.Errorf("failed to resolve ID") 51 + } 52 + did := ident.DID.String() 53 + 54 + profile, err := db.GetProfile(s.db, did) 55 + if err != nil { 56 + return nil, fmt.Errorf("failed to get profile: %w", err) 57 + } 58 + 59 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 60 + if err != nil { 61 + return nil, fmt.Errorf("failed to get repo count: %w", err) 62 + } 63 + 64 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 65 + if err != nil { 66 + return nil, fmt.Errorf("failed to get string count: %w", err) 67 + } 68 + 69 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 + } 73 + 74 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 75 + if err != nil { 76 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 77 + } 78 + 79 + loggedInUser := s.oauth.GetUser(r) 80 + followStatus := db.IsNotFollowing 81 + if loggedInUser != nil { 82 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 83 + } 84 + 85 + now := time.Now() 86 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 87 + punchcard, err := db.MakePunchcard( 88 + s.db, 89 + db.FilterEq("did", did), 90 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 91 + db.FilterLte("date", now.Format(time.DateOnly)), 92 + ) 93 + if err != nil { 94 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 42 95 } 43 96 44 - profile, err := db.GetProfile(s.db, ident.DID.String()) 97 + return &pages.ProfileCard{ 98 + UserDid: did, 99 + UserHandle: ident.Handle.String(), 100 + Profile: profile, 101 + FollowStatus: followStatus, 102 + Stats: pages.ProfileStats{ 103 + RepoCount: repoCount, 104 + StringCount: stringCount, 105 + StarredCount: starredCount, 106 + FollowersCount: followStats.Followers, 107 + FollowingCount: followStats.Following, 108 + }, 109 + Punchcard: punchcard, 110 + }, nil 111 + } 112 + 113 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 114 + l := s.logger.With("handler", "profileHomePage") 115 + 116 + profile, err := s.profile(r) 45 117 if err != nil { 46 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 118 + l.Error("failed to build profile card", "err", err) 119 + s.pages.Error500(w) 120 + return 47 121 } 122 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 48 123 49 124 repos, err := db.GetRepos( 50 125 s.db, 51 126 0, 52 - db.FilterEq("did", ident.DID.String()), 127 + db.FilterEq("did", profile.UserDid), 53 128 ) 54 129 if err != nil { 55 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 130 + l.Error("failed to fetch repos", "err", err) 56 131 } 57 132 58 133 // filter out ones that are pinned 59 134 pinnedRepos := []db.Repo{} 60 135 for i, r := range repos { 61 136 // if this is a pinned repo, add it 62 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 137 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 63 138 pinnedRepos = append(pinnedRepos, r) 64 139 } 65 140 66 141 // if there are no saved pins, add the first 4 repos 67 - if profile.IsPinnedReposEmpty() && i < 4 { 142 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 68 143 pinnedRepos = append(pinnedRepos, r) 69 144 } 70 145 } 71 146 72 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 147 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 73 148 if err != nil { 74 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 149 + l.Error("failed to fetch collaborating repos", "err", err) 75 150 } 76 151 77 152 pinnedCollaboratingRepos := []db.Repo{} 78 153 for _, r := range collaboratingRepos { 79 154 // if this is a pinned repo, add it 80 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 155 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 81 156 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 82 157 } 83 158 } 84 159 85 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 160 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 86 161 if err != nil { 87 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 162 + l.Error("failed to create timeline", "err", err) 88 163 } 89 164 90 - var didsToResolve []string 91 - for _, r := range collaboratingRepos { 92 - didsToResolve = append(didsToResolve, r.Did) 165 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 + LoggedInUser: s.oauth.GetUser(r), 167 + Card: profile, 168 + Repos: pinnedRepos, 169 + CollaboratingRepos: pinnedCollaboratingRepos, 170 + ProfileTimeline: timeline, 171 + }) 172 + } 173 + 174 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 175 + l := s.logger.With("handler", "reposPage") 176 + 177 + profile, err := s.profile(r) 178 + if err != nil { 179 + l.Error("failed to build profile card", "err", err) 180 + s.pages.Error500(w) 181 + return 93 182 } 94 - for _, byMonth := range timeline.ByMonth { 95 - for _, pe := range byMonth.PullEvents.Items { 96 - didsToResolve = append(didsToResolve, pe.Repo.Did) 183 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 184 + 185 + repos, err := db.GetRepos( 186 + s.db, 187 + 0, 188 + db.FilterEq("did", profile.UserDid), 189 + ) 190 + if err != nil { 191 + l.Error("failed to get repos", "err", err) 192 + s.pages.Error500(w) 193 + return 194 + } 195 + 196 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 197 + LoggedInUser: s.oauth.GetUser(r), 198 + Repos: repos, 199 + Card: profile, 200 + }) 201 + } 202 + 203 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 204 + l := s.logger.With("handler", "starredPage") 205 + 206 + profile, err := s.profile(r) 207 + if err != nil { 208 + l.Error("failed to build profile card", "err", err) 209 + s.pages.Error500(w) 210 + return 211 + } 212 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 213 + 214 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 215 + if err != nil { 216 + l.Error("failed to get stars", "err", err) 217 + s.pages.Error500(w) 218 + return 219 + } 220 + var repoAts []string 221 + for _, s := range stars { 222 + repoAts = append(repoAts, string(s.RepoAt)) 223 + } 224 + 225 + repos, err := db.GetRepos( 226 + s.db, 227 + 0, 228 + db.FilterIn("at_uri", repoAts), 229 + ) 230 + if err != nil { 231 + l.Error("failed to get repos", "err", err) 232 + s.pages.Error500(w) 233 + return 234 + } 235 + 236 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 237 + LoggedInUser: s.oauth.GetUser(r), 238 + Repos: repos, 239 + Card: profile, 240 + }) 241 + } 242 + 243 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 244 + l := s.logger.With("handler", "stringsPage") 245 + 246 + profile, err := s.profile(r) 247 + if err != nil { 248 + l.Error("failed to build profile card", "err", err) 249 + s.pages.Error500(w) 250 + return 251 + } 252 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 253 + 254 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 255 + if err != nil { 256 + l.Error("failed to get strings", "err", err) 257 + s.pages.Error500(w) 258 + return 259 + } 260 + 261 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 262 + LoggedInUser: s.oauth.GetUser(r), 263 + Strings: strings, 264 + Card: profile, 265 + }) 266 + } 267 + 268 + type FollowsPageParams struct { 269 + Follows []pages.FollowCard 270 + Card *pages.ProfileCard 271 + } 272 + 273 + func (s *State) followPage( 274 + r *http.Request, 275 + fetchFollows func(db.Execer, string) ([]db.Follow, error), 276 + extractDid func(db.Follow) string, 277 + ) (*FollowsPageParams, error) { 278 + l := s.logger.With("handler", "reposPage") 279 + 280 + profile, err := s.profile(r) 281 + if err != nil { 282 + return nil, err 283 + } 284 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 285 + 286 + loggedInUser := s.oauth.GetUser(r) 287 + 288 + follows, err := fetchFollows(s.db, profile.UserDid) 289 + if err != nil { 290 + l.Error("failed to fetch follows", "err", err) 291 + return nil, err 292 + } 293 + 294 + if len(follows) == 0 { 295 + return nil, nil 296 + } 297 + 298 + followDids := make([]string, 0, len(follows)) 299 + for _, follow := range follows { 300 + followDids = append(followDids, extractDid(follow)) 301 + } 302 + 303 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 304 + if err != nil { 305 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 306 + return nil, err 307 + } 308 + 309 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 310 + if err != nil { 311 + log.Printf("getting follow counts for %s: %s", followDids, err) 312 + } 313 + 314 + loggedInUserFollowing := make(map[string]struct{}) 315 + if loggedInUser != nil { 316 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 317 + if err != nil { 318 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 319 + return nil, err 97 320 } 98 - for _, ie := range byMonth.IssueEvents.Items { 99 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 100 - } 101 - for _, re := range byMonth.RepoEvents { 102 - didsToResolve = append(didsToResolve, re.Repo.Did) 103 - if re.Source != nil { 104 - didsToResolve = append(didsToResolve, re.Source.Did) 105 - } 321 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 322 + for _, follow := range following { 323 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 106 324 } 107 325 } 108 326 109 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 110 - didHandleMap := make(map[string]string) 111 - for _, identity := range resolvedIds { 112 - if !identity.Handle.IsInvalidHandle() { 113 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 327 + followCards := make([]pages.FollowCard, len(follows)) 328 + for i, did := range followDids { 329 + followStats := followStatsMap[did] 330 + followStatus := db.IsNotFollowing 331 + if _, exists := loggedInUserFollowing[did]; exists { 332 + followStatus = db.IsFollowing 333 + } else if loggedInUser != nil && loggedInUser.Did == did { 334 + followStatus = db.IsSelf 335 + } 336 + 337 + var profile *db.Profile 338 + if p, exists := profiles[did]; exists { 339 + profile = p 114 340 } else { 115 - didHandleMap[identity.DID.String()] = identity.DID.String() 341 + profile = &db.Profile{} 342 + profile.Did = did 343 + } 344 + followCards[i] = pages.FollowCard{ 345 + UserDid: did, 346 + FollowStatus: followStatus, 347 + FollowersCount: followStats.Followers, 348 + FollowingCount: followStats.Following, 349 + Profile: profile, 116 350 } 117 351 } 118 352 119 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 353 + return &FollowsPageParams{ 354 + Follows: followCards, 355 + Card: profile, 356 + }, nil 357 + } 358 + 359 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 360 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 120 361 if err != nil { 121 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 362 + s.pages.Notice(w, "all-followers", "Failed to load followers") 363 + return 122 364 } 123 365 124 - loggedInUser := s.oauth.GetUser(r) 125 - followStatus := db.IsNotFollowing 126 - if loggedInUser != nil { 127 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 128 - } 366 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 367 + LoggedInUser: s.oauth.GetUser(r), 368 + Followers: followPage.Follows, 369 + Card: followPage.Card, 370 + }) 371 + } 129 372 130 - now := time.Now() 131 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 132 - punchcard, err := db.MakePunchcard( 133 - s.db, 134 - db.FilterEq("did", ident.DID.String()), 135 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 136 - db.FilterLte("date", now.Format(time.DateOnly)), 137 - ) 373 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 374 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 138 375 if err != nil { 139 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 376 + s.pages.Notice(w, "all-following", "Failed to load following") 377 + return 140 378 } 141 379 142 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 143 - LoggedInUser: loggedInUser, 144 - Repos: pinnedRepos, 145 - CollaboratingRepos: pinnedCollaboratingRepos, 146 - DidHandleMap: didHandleMap, 147 - Card: pages.ProfileCard{ 148 - UserDid: ident.DID.String(), 149 - UserHandle: ident.Handle.String(), 150 - Profile: profile, 151 - FollowStatus: followStatus, 152 - Followers: followers, 153 - Following: following, 154 - }, 155 - Punchcard: punchcard, 156 - ProfileTimeline: timeline, 380 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 381 + LoggedInUser: s.oauth.GetUser(r), 382 + Following: followPage.Follows, 383 + Card: followPage.Card, 157 384 }) 158 385 } 159 386 160 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 387 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 161 388 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 162 389 if !ok { 163 390 s.pages.Error404(w) 164 391 return 165 392 } 166 393 167 - profile, err := db.GetProfile(s.db, ident.DID.String()) 394 + feed, err := s.getProfileFeed(r.Context(), &ident) 395 + if err != nil { 396 + s.pages.Error500(w) 397 + return 398 + } 399 + 400 + if feed == nil { 401 + return 402 + } 403 + 404 + atom, err := feed.ToAtom() 168 405 if err != nil { 169 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 406 + s.pages.Error500(w) 407 + return 170 408 } 171 409 172 - repos, err := db.GetRepos( 173 - s.db, 174 - 0, 175 - db.FilterEq("did", ident.DID.String()), 176 - ) 410 + w.Header().Set("content-type", "application/atom+xml") 411 + w.Write([]byte(atom)) 412 + } 413 + 414 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 415 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 177 416 if err != nil { 178 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 417 + return nil, err 418 + } 419 + 420 + author := &feeds.Author{ 421 + Name: fmt.Sprintf("@%s", id.Handle), 179 422 } 180 423 181 - loggedInUser := s.oauth.GetUser(r) 182 - followStatus := db.IsNotFollowing 183 - if loggedInUser != nil { 184 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 424 + feed := feeds.Feed{ 425 + Title: fmt.Sprintf("%s's timeline", author.Name), 426 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 427 + Items: make([]*feeds.Item, 0), 428 + Updated: time.UnixMilli(0), 429 + Author: author, 185 430 } 186 431 187 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 188 - if err != nil { 189 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 432 + for _, byMonth := range timeline.ByMonth { 433 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 434 + return nil, err 435 + } 436 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 437 + return nil, err 438 + } 439 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 440 + return nil, err 441 + } 190 442 } 191 443 192 - s.pages.ReposPage(w, pages.ReposPageParams{ 193 - LoggedInUser: loggedInUser, 194 - Repos: repos, 195 - DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 196 - Card: pages.ProfileCard{ 197 - UserDid: ident.DID.String(), 198 - UserHandle: ident.Handle.String(), 199 - Profile: profile, 200 - FollowStatus: followStatus, 201 - Followers: followers, 202 - Following: following, 203 - }, 444 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 445 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 204 446 }) 447 + 448 + if len(feed.Items) > 0 { 449 + feed.Updated = feed.Items[0].Created 450 + } 451 + 452 + return &feed, nil 453 + } 454 + 455 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 456 + for _, pull := range pulls { 457 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 458 + if err != nil { 459 + return err 460 + } 461 + 462 + // Add pull request creation item 463 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 464 + } 465 + return nil 466 + } 467 + 468 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 469 + for _, issue := range issues { 470 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 471 + if err != nil { 472 + return err 473 + } 474 + 475 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 476 + } 477 + return nil 478 + } 479 + 480 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 481 + for _, repo := range repos { 482 + item, err := s.createRepoItem(ctx, repo, author) 483 + if err != nil { 484 + return err 485 + } 486 + feed.Items = append(feed.Items, item) 487 + } 488 + return nil 489 + } 490 + 491 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 492 + return &feeds.Item{ 493 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 494 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 495 + Created: pull.Created, 496 + Author: author, 497 + } 498 + } 499 + 500 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 501 + return &feeds.Item{ 502 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 503 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 504 + Created: issue.Created, 505 + Author: author, 506 + } 507 + } 508 + 509 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 510 + var title string 511 + if repo.Source != nil { 512 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 513 + if err != nil { 514 + return nil, err 515 + } 516 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 517 + } else { 518 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 519 + } 520 + 521 + return &feeds.Item{ 522 + Title: title, 523 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 524 + Created: repo.Repo.Created, 525 + Author: author, 526 + }, nil 205 527 } 206 528 207 529 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 406 728 }) 407 729 } 408 730 409 - var didsToResolve []string 410 - for _, r := range allRepos { 411 - didsToResolve = append(didsToResolve, r.Did) 412 - } 413 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 414 - didHandleMap := make(map[string]string) 415 - for _, identity := range resolvedIds { 416 - if !identity.Handle.IsInvalidHandle() { 417 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 418 - } else { 419 - didHandleMap[identity.DID.String()] = identity.DID.String() 420 - } 421 - } 422 - 423 731 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 424 732 LoggedInUser: user, 425 733 Profile: profile, 426 734 AllRepos: allRepos, 427 - DidHandleMap: didHandleMap, 428 735 }) 429 736 }
+18 -6
appview/state/router.go
··· 32 32 s.pages, 33 33 ) 34 34 35 + router.Get("/favicon.svg", s.Favicon) 36 + router.Get("/favicon.ico", s.Favicon) 37 + 38 + userRouter := s.UserRouter(&middleware) 39 + standardRouter := s.StandardRouter(&middleware) 40 + 35 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 36 42 pat := chi.URLParam(r, "*") 37 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 38 - s.UserRouter(&middleware).ServeHTTP(w, r) 44 + userRouter.ServeHTTP(w, r) 39 45 } else { 40 46 // Check if the first path element is a valid handle without '@' or a flattened DID 41 47 pathParts := strings.SplitN(pat, "/", 2) ··· 58 64 return 59 65 } 60 66 } 61 - s.StandardRouter(&middleware).ServeHTTP(w, r) 67 + standardRouter.ServeHTTP(w, r) 62 68 } 63 69 }) 64 70 ··· 70 76 71 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 78 r.Get("/", s.Profile) 79 + r.Get("/feed.atom", s.AtomFeedPage) 80 + 81 + // redirect /@handle/repo.git -> /@handle/repo 82 + r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 83 + nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 84 + http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 85 + }) 73 86 74 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 75 88 r.Use(mw.GoImport()) 76 - 77 89 r.Mount("/", s.RepoRouter(mw)) 78 90 r.Mount("/issues", s.IssuesRouter(mw)) 79 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 135 147 136 148 r.Mount("/settings", s.SettingsRouter()) 137 149 r.Mount("/strings", s.StringsRouter(mw)) 138 - r.Mount("/knots", s.KnotsRouter(mw)) 150 + r.Mount("/knots", s.KnotsRouter()) 139 151 r.Mount("/spindles", s.SpindlesRouter()) 140 152 r.Mount("/signup", s.SignupRouter()) 141 153 r.Mount("/", s.OAuthRouter()) ··· 183 195 return spindles.Router() 184 196 } 185 197 186 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 198 + func (s *State) KnotsRouter() http.Handler { 187 199 logger := log.New("knots") 188 200 189 201 knots := &knots.Knots{ ··· 197 209 Logger: logger, 198 210 } 199 211 200 - return knots.Router(mw) 212 + return knots.Router() 201 213 } 202 214 203 215 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+123 -68
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 6 + "errors" 5 7 "fmt" 6 8 "log" 7 9 "log/slog" ··· 10 12 "time" 11 13 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 13 16 lexutil "github.com/bluesky-social/indigo/lex/util" 14 17 securejoin "github.com/cyphar/filepath-securejoin" 15 18 "github.com/go-chi/chi/v5" ··· 25 28 "tangled.sh/tangled.sh/core/appview/pages" 26 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 28 32 "tangled.sh/tangled.sh/core/eventconsumer" 29 33 "tangled.sh/tangled.sh/core/idresolver" 30 34 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 35 tlog "tangled.sh/tangled.sh/core/log" 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 38 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 35 39 ) 36 40 37 41 type State struct { ··· 48 52 repoResolver *reporesolver.RepoResolver 49 53 knotstream *eventconsumer.Consumer 50 54 spindlestream *eventconsumer.Consumer 55 + logger *slog.Logger 51 56 } 52 57 53 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 61 66 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 67 } 63 68 64 - pgs := pages.NewPages(config) 65 - 66 69 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 70 if err != nil { 68 71 log.Printf("failed to create redis resolver: %v", err) 69 72 res = idresolver.DefaultResolver() 70 73 } 74 + 75 + pgs := pages.NewPages(config, res) 71 76 72 77 cache := cache.New(config.Redis.Addr) 73 78 sess := session.New(cache) ··· 94 99 tangled.SpindleMemberNSID, 95 100 tangled.SpindleNSID, 96 101 tangled.StringNSID, 102 + tangled.RepoIssueNSID, 103 + tangled.RepoIssueCommentNSID, 97 104 }, 98 105 nil, 99 106 slog.Default(), ··· 152 159 repoResolver, 153 160 knotstream, 154 161 spindlestream, 162 + slog.Default(), 155 163 } 156 164 157 165 return state, nil 158 166 } 159 167 168 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 169 + w.Header().Set("Content-Type", "image/svg+xml") 170 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 171 + w.Header().Set("ETag", `"favicon-svg-v1"`) 172 + 173 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 174 + w.WriteHeader(http.StatusNotModified) 175 + return 176 + } 177 + 178 + s.pages.Favicon(w) 179 + } 180 + 160 181 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 161 182 user := s.oauth.GetUser(r) 162 183 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 180 201 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 181 202 } 182 203 183 - var didsToResolve []string 184 - for _, ev := range timeline { 185 - if ev.Repo != nil { 186 - didsToResolve = append(didsToResolve, ev.Repo.Did) 187 - if ev.Source != nil { 188 - didsToResolve = append(didsToResolve, ev.Source.Did) 189 - } 190 - } 191 - if ev.Follow != nil { 192 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 193 - } 194 - if ev.Star != nil { 195 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 196 - } 197 - } 198 - 199 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 200 - didHandleMap := make(map[string]string) 201 - for _, identity := range resolvedIds { 202 - if !identity.Handle.IsInvalidHandle() { 203 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 204 - } else { 205 - didHandleMap[identity.DID.String()] = identity.DID.String() 206 - } 204 + repos, err := db.GetTopStarredReposLastWeek(s.db) 205 + if err != nil { 206 + log.Println(err) 207 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 208 + return 207 209 } 208 210 209 211 s.pages.Timeline(w, pages.TimelineParams{ 210 212 LoggedInUser: user, 211 213 Timeline: timeline, 212 - DidHandleMap: didHandleMap, 214 + Repos: repos, 213 215 }) 214 - 215 - return 216 216 } 217 217 218 218 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 279 279 return nil 280 280 } 281 281 282 + func stripGitExt(name string) string { 283 + return strings.TrimSuffix(name, ".git") 284 + } 285 + 282 286 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 283 287 switch r.Method { 284 288 case http.MethodGet: ··· 295 299 }) 296 300 297 301 case http.MethodPost: 302 + l := s.logger.With("handler", "NewRepo") 303 + 298 304 user := s.oauth.GetUser(r) 305 + l = l.With("did", user.Did) 306 + l = l.With("handle", user.Handle) 299 307 308 + // form validation 300 309 domain := r.FormValue("domain") 301 310 if domain == "" { 302 311 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 303 312 return 304 313 } 314 + l = l.With("knot", domain) 305 315 306 316 repoName := r.FormValue("name") 307 317 if repoName == "" { ··· 313 323 s.pages.Notice(w, "repo", err.Error()) 314 324 return 315 325 } 326 + repoName = stripGitExt(repoName) 327 + l = l.With("repoName", repoName) 316 328 317 329 defaultBranch := r.FormValue("branch") 318 330 if defaultBranch == "" { 319 331 defaultBranch = "main" 320 332 } 333 + l = l.With("defaultBranch", defaultBranch) 321 334 322 335 description := r.FormValue("description") 323 336 337 + // ACL validation 324 338 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 325 339 if err != nil || !ok { 340 + l.Info("unauthorized") 326 341 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 327 342 return 328 343 } 329 344 345 + // Check for existing repos 330 346 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 331 347 if err == nil && existingRepo != nil { 332 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 333 - return 334 - } 335 - 336 - secret, err := db.GetRegistrationKey(s.db, domain) 337 - if err != nil { 338 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 339 - return 340 - } 341 - 342 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 343 - if err != nil { 344 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 348 + l.Info("repo exists") 349 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 345 350 return 346 351 } 347 352 353 + // create atproto record for this repo 348 354 rkey := tid.TID() 349 355 repo := &db.Repo{ 350 356 Did: user.Did, ··· 356 362 357 363 xrpcClient, err := s.oauth.AuthorizedClient(r) 358 364 if err != nil { 365 + l.Info("PDS write failed", "err", err) 359 366 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 360 367 return 361 368 } ··· 374 381 }}, 375 382 }) 376 383 if err != nil { 377 - log.Printf("failed to create record: %s", err) 384 + l.Info("PDS write failed", "err", err) 378 385 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 379 386 return 380 387 } 381 - log.Println("created repo record: ", atresp.Uri) 388 + 389 + aturi := atresp.Uri 390 + l = l.With("aturi", aturi) 391 + l.Info("wrote to PDS") 382 392 383 393 tx, err := s.db.BeginTx(r.Context(), nil) 384 394 if err != nil { 385 - log.Println(err) 395 + l.Info("txn failed", "err", err) 386 396 s.pages.Notice(w, "repo", "Failed to save repository information.") 387 397 return 388 398 } 389 - defer func() { 390 - tx.Rollback() 391 - err = s.enforcer.E.LoadPolicy() 392 - if err != nil { 393 - log.Println("failed to rollback policies") 399 + 400 + // The rollback function reverts a few things on failure: 401 + // - the pending txn 402 + // - the ACLs 403 + // - the atproto record created 404 + rollback := func() { 405 + err1 := tx.Rollback() 406 + err2 := s.enforcer.E.LoadPolicy() 407 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 408 + 409 + // ignore txn complete errors, this is okay 410 + if errors.Is(err1, sql.ErrTxDone) { 411 + err1 = nil 394 412 } 395 - }() 396 413 397 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 414 + if errs := errors.Join(err1, err2, err3); errs != nil { 415 + l.Error("failed to rollback changes", "errs", errs) 416 + return 417 + } 418 + } 419 + defer rollback() 420 + 421 + client, err := s.oauth.ServiceClient( 422 + r, 423 + oauth.WithService(domain), 424 + oauth.WithLxm(tangled.RepoCreateNSID), 425 + oauth.WithDev(s.config.Core.Dev), 426 + ) 398 427 if err != nil { 399 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 428 + l.Error("service auth failed", "err", err) 429 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 400 430 return 401 431 } 402 432 403 - switch resp.StatusCode { 404 - case http.StatusConflict: 405 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 433 + xe := tangled.RepoCreate( 434 + r.Context(), 435 + client, 436 + &tangled.RepoCreate_Input{ 437 + Rkey: rkey, 438 + }, 439 + ) 440 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 441 + l.Error("xrpc error", "xe", xe) 442 + s.pages.Notice(w, "repo", err.Error()) 406 443 return 407 - case http.StatusInternalServerError: 408 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 409 - case http.StatusNoContent: 410 - // continue 411 444 } 412 445 413 - repo.AtUri = atresp.Uri 414 446 err = db.AddRepo(tx, repo) 415 447 if err != nil { 416 - log.Println(err) 448 + l.Error("db write failed", "err", err) 417 449 s.pages.Notice(w, "repo", "Failed to save repository information.") 418 450 return 419 451 } ··· 422 454 p, _ := securejoin.SecureJoin(user.Did, repoName) 423 455 err = s.enforcer.AddRepo(user.Did, domain, p) 424 456 if err != nil { 425 - log.Println(err) 457 + l.Error("acl setup failed", "err", err) 426 458 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 427 459 return 428 460 } 429 461 430 462 err = tx.Commit() 431 463 if err != nil { 432 - log.Println("failed to commit changes", err) 464 + l.Error("txn commit failed", "err", err) 433 465 http.Error(w, err.Error(), http.StatusInternalServerError) 434 466 return 435 467 } 436 468 437 469 err = s.enforcer.E.SavePolicy() 438 470 if err != nil { 439 - log.Println("failed to update ACLs", err) 471 + l.Error("acl save failed", "err", err) 440 472 http.Error(w, err.Error(), http.StatusInternalServerError) 441 473 return 442 474 } 443 475 476 + // reset the ATURI because the transaction completed successfully 477 + aturi = "" 478 + 444 479 s.notifier.NewRepo(r.Context(), repo) 480 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 481 + } 482 + } 445 483 446 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 447 - return 484 + // this is used to rollback changes made to the PDS 485 + // 486 + // it is a no-op if the provided ATURI is empty 487 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 488 + if aturi == "" { 489 + return nil 448 490 } 491 + 492 + parsed := syntax.ATURI(aturi) 493 + 494 + collection := parsed.Collection().String() 495 + repo := parsed.Authority().String() 496 + rkey := parsed.RecordKey().String() 497 + 498 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 499 + Collection: collection, 500 + Repo: repo, 501 + Rkey: rkey, 502 + }) 503 + return err 449 504 }
+23 -70
appview/strings/strings.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "path" 8 - "slices" 9 8 "strconv" 10 - "strings" 11 9 "time" 12 10 13 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 44 42 r := chi.NewRouter() 45 43 46 44 r. 45 + Get("/", s.timeline) 46 + 47 + r. 47 48 With(mw.ResolveIdent()). 48 49 Route("/{user}", func(r chi.Router) { 49 50 r.Get("/", s.dashboard) ··· 70 71 return r 71 72 } 72 73 74 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 75 + l := s.Logger.With("handler", "timeline") 76 + 77 + strings, err := db.GetStrings(s.Db, 50) 78 + if err != nil { 79 + l.Error("failed to fetch string", "err", err) 80 + w.WriteHeader(http.StatusInternalServerError) 81 + return 82 + } 83 + 84 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 85 + LoggedInUser: s.OAuth.GetUser(r), 86 + Strings: strings, 87 + }) 88 + } 89 + 73 90 func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 91 l := s.Logger.With("handler", "contents") 75 92 ··· 91 108 92 109 strings, err := db.GetStrings( 93 110 s.Db, 111 + 0, 94 112 db.FilterEq("did", id.DID), 95 113 db.FilterEq("rkey", rkey), 96 114 ) ··· 142 160 } 143 161 144 162 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 145 - l := s.Logger.With("handler", "dashboard") 146 - 147 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 148 - if !ok { 149 - l.Error("malformed middleware") 150 - w.WriteHeader(http.StatusInternalServerError) 151 - return 152 - } 153 - l = l.With("did", id.DID, "handle", id.Handle) 154 - 155 - all, err := db.GetStrings( 156 - s.Db, 157 - db.FilterEq("did", id.DID), 158 - ) 159 - if err != nil { 160 - l.Error("failed to fetch strings", "err", err) 161 - w.WriteHeader(http.StatusInternalServerError) 162 - return 163 - } 164 - 165 - slices.SortFunc(all, func(a, b db.String) int { 166 - if a.Created.After(b.Created) { 167 - return -1 168 - } else { 169 - return 1 170 - } 171 - }) 172 - 173 - profile, err := db.GetProfile(s.Db, id.DID.String()) 174 - if err != nil { 175 - l.Error("failed to fetch user profile", "err", err) 176 - w.WriteHeader(http.StatusInternalServerError) 177 - return 178 - } 179 - loggedInUser := s.OAuth.GetUser(r) 180 - followStatus := db.IsNotFollowing 181 - if loggedInUser != nil { 182 - followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 183 - } 184 - 185 - followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 186 - if err != nil { 187 - l.Error("failed to get follow stats", "err", err) 188 - } 189 - 190 - s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 191 - LoggedInUser: s.OAuth.GetUser(r), 192 - Card: pages.ProfileCard{ 193 - UserDid: id.DID.String(), 194 - UserHandle: id.Handle.String(), 195 - Profile: profile, 196 - FollowStatus: followStatus, 197 - Followers: followers, 198 - Following: following, 199 - }, 200 - Strings: all, 201 - }) 163 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 202 164 } 203 165 204 166 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { ··· 225 187 // get the string currently being edited 226 188 all, err := db.GetStrings( 227 189 s.Db, 190 + 0, 228 191 db.FilterEq("did", id.DID), 229 192 db.FilterEq("rkey", rkey), 230 193 ) ··· 266 229 fail("Empty filename.", nil) 267 230 return 268 231 } 269 - if !strings.Contains(filename, ".") { 270 - // TODO: make this a htmx form validation 271 - fail("No extension provided for filename.", nil) 272 - return 273 - } 274 232 275 233 content := r.FormValue("content") 276 234 if content == "" { ··· 353 311 fail("Empty filename.", nil) 354 312 return 355 313 } 356 - if !strings.Contains(filename, ".") { 357 - // TODO: make this a htmx form validation 358 - fail("No extension provided for filename.", nil) 359 - return 360 - } 361 314 362 315 content := r.FormValue("content") 363 316 if content == "" { ··· 434 387 } 435 388 436 389 if user.Did != id.DID.String() { 437 - fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 390 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 391 return 439 392 } 440 393
+25
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 7 + "fmt" 6 8 "io" 9 + "net/http" 7 10 8 11 "github.com/bluesky-social/indigo/api/atproto" 9 12 "github.com/bluesky-social/indigo/xrpc" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 15 ) 12 16 ··· 102 106 103 107 return &out, nil 104 108 } 109 + 110 + // produces a more manageable error 111 + func HandleXrpcErr(err error) error { 112 + if err == nil { 113 + return nil 114 + } 115 + 116 + var xrpcerr *indigoxrpc.Error 117 + if ok := errors.As(err, &xrpcerr); !ok { 118 + return fmt.Errorf("Recieved invalid XRPC error response.") 119 + } 120 + 121 + switch xrpcerr.StatusCode { 122 + case http.StatusNotFound: 123 + return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 + case http.StatusUnauthorized: 125 + return fmt.Errorf("Unauthorized XRPC request.") 126 + default: 127 + return fmt.Errorf("Failed to perform operation. Try again later.") 128 + } 129 + }
+6 -6
cmd/gen.go
··· 18 18 tangled.FeedReaction{}, 19 19 tangled.FeedStar{}, 20 20 tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_LangBreakdown{}, 24 + tangled.GitRefUpdate_IndividualLanguageSize{}, 21 25 tangled.GitRefUpdate_Meta{}, 22 - tangled.GitRefUpdate_Meta_CommitCount{}, 23 - tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 24 - tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 - tangled.GitRefUpdate_Pair{}, 26 26 tangled.GraphFollow{}, 27 + tangled.Knot{}, 27 28 tangled.KnotMember{}, 28 29 tangled.Pipeline{}, 29 30 tangled.Pipeline_CloneOpts{}, 30 - tangled.Pipeline_Dependency{}, 31 31 tangled.Pipeline_ManualTriggerData{}, 32 32 tangled.Pipeline_Pair{}, 33 33 tangled.Pipeline_PullRequestTriggerData{}, 34 34 tangled.Pipeline_PushTriggerData{}, 35 35 tangled.PipelineStatus{}, 36 - tangled.Pipeline_Step{}, 37 36 tangled.Pipeline_TriggerMetadata{}, 38 37 tangled.Pipeline_TriggerRepo{}, 39 38 tangled.Pipeline_Workflow{}, ··· 48 47 tangled.RepoPullComment{}, 49 48 tangled.RepoPull_Source{}, 50 49 tangled.RepoPullStatus{}, 50 + tangled.RepoPull_Target{}, 51 51 tangled.Spindle{}, 52 52 tangled.SpindleMember{}, 53 53 tangled.String{},
+4
cmd/genjwks/main.go
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+1 -1
cmd/punchcardPopulate/main.go
··· 11 11 ) 12 12 13 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 15 if err != nil { 16 16 log.Fatal("Failed to open database:", err) 17 17 }
+17 -18
docs/contributing.md
··· 11 11 ### message format 12 12 13 13 ``` 14 - <service/top-level directory>: <affected package/directory>: <short summary of change> 14 + <service/top-level directory>/<affected package/directory>: <short summary of change> 15 15 16 16 17 17 Optional longer description can go here, if necessary. Explain what the ··· 23 23 Here are some examples: 24 24 25 25 ``` 26 - appview: state: fix token expiry check in middleware 26 + appview/state: fix token expiry check in middleware 27 27 28 28 The previous check did not account for clock drift, leading to premature 29 29 token invalidation. 30 30 ``` 31 31 32 32 ``` 33 - knotserver: git/service: improve error checking in upload-pack 33 + knotserver/git/service: improve error checking in upload-pack 34 34 ``` 35 35 36 36 ··· 54 54 - Don't include unrelated changes in the same commit. 55 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 56 before submitting if necessary. 57 + 58 + ## code formatting 59 + 60 + We use a variety of tools to format our code, and multiplex them with 61 + [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 57 63 58 64 ## proposals for bigger changes 59 65 ··· 115 121 If you're submitting a PR with multiple commits, make sure each one is 116 122 signed. 117 123 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 124 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 + to make it sign off commits in the tangled repo: 120 126 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 127 + ```shell 128 + # Safety check, should say "No matching config key..." 129 + jj config list templates.commit_trailers 130 + # The command below may need to be adjusted if the command above returned something. 131 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 132 ``` 134 133 135 134 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 135 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 136 for more information.
+66 -19
docs/hacking.md
··· 48 48 redis-server 49 49 ``` 50 50 51 - ## running a knot 51 + ## running knots and spindles 52 52 53 53 An end-to-end knot setup requires setting up a machine with 54 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, head to `http://localhost:3000/knots` in the browser 59 - and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it, 60 - ideally in a `.envrc` with [direnv](https://direnv.net) so you 61 - don't lose it. 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 60 + 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 + 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 90 + 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 96 + 97 + </details> 62 98 63 - You can now start a lightweight NixOS VM using 64 - `nixos-shell` like so: 99 + To begin, grab your DID from http://localhost:3000/settings. 100 + Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 65 103 66 104 ```bash 67 - nix run .#vm 68 - # or nixos-shell --flake .#vm 105 + nix run --impure .#vm 69 106 70 - # hit Ctrl-a + c + q to exit the VM 107 + # type `poweroff` at the shell to exit the VM 71 108 ``` 72 109 73 110 This starts a knot on port 6000, a spindle on port 6555 74 - with `ssh` exposed on port 2222. You can push repositories 75 - to this VM with this ssh config block on your main machine: 111 + with `ssh` exposed on port 2222. 112 + 113 + Once the services are running, head to 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 117 + 118 + You can push repositories to this VM with this ssh config 119 + block on your main machine: 76 120 77 121 ```bash 78 122 Host nixos-shell ··· 89 133 git push local-dev main 90 134 ``` 91 135 92 - ## running a spindle 136 + ### running a spindle 93 137 94 - Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID. 95 - The above VM should already be running a spindle on `localhost:6555`. 96 - You can head to the spindle dashboard on `http://localhost:3000/spindles`, 97 - and register a spindle with hostname `localhost:6555`. It should instantly 98 - be verified. You can then configure each repository to use this spindle 99 - and run CI jobs. 138 + The above VM should already be running a spindle on 139 + `localhost:6555`. Head to http://localhost:3000/spindles and 140 + hit verify. You can then configure each repository to use 141 + this spindle and run CI jobs. 100 142 101 143 Of interest when debugging spindles: 102 144 ··· 113 155 # litecli has a nicer REPL interface: 114 156 litecli /var/lib/spindle/spindle.db 115 157 ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
+14 -6
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 59 59 EOF 60 60 ``` 61 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 62 68 Next, create the `git` user. We'll use the `git` user's home directory 63 69 to store repositories: 64 70 ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_OWNER` should be set to your 77 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git 75 81 KNOT_SERVER_HOSTNAME=knot.example.com 76 82 APPVIEW_ENDPOINT=https://tangled.sh 77 - KNOT_SERVER_SECRET=secret 83 + KNOT_SERVER_OWNER=did:plc:foobar 78 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 79 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 80 86 ``` ··· 122 128 Remember to use Let's Encrypt or similar to procure a certificate for your 123 129 knot domain. 124 130 125 - You should now have a running knot server! You can finalize your registration by hitting the 126 - `initialize` button on the [/knots](/knots) page. 131 + You should now have a running knot server! You can finalize 132 + your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.sh/knots) page. This simply creates 134 + a record on your PDS to announce the existence of the knot. 127 135 128 136 ### custom paths 129 137
+35
docs/migrations/knot-1.7.0.md
··· 1 + # Upgrading from v1.7.0 2 + 3 + After v1.7.0, knot secrets have been deprecated. You no 4 + longer need a secret from the appview to run a knot. All 5 + authorized commands to knots are managed via [Inter-Service 6 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 + Knots will be read-only until upgraded. 8 + 9 + Upgrading is quite easy, in essence: 10 + 11 + - `KNOT_SERVER_SECRET` is no more, you can remove this 12 + environment variable entirely 13 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 + your DID. You can find your DID in the 15 + [settings](https://tangled.sh/settings) page. 16 + - Restart your knot once you have replaced the environment 17 + variable 18 + - Head to the [knot dashboard](https://tangled.sh/knots) and 19 + hit the "retry" button to verify your knot. This simply 20 + writes a `sh.tangled.knot` record to your PDS. 21 + 22 + ## Nix 23 + 24 + If you use the nix module, simply bump the flake to the 25 + latest revision, and change your config block like so: 26 + 27 + ```diff 28 + services.tangled-knot = { 29 + enable = true; 30 + server = { 31 + - secretFile = /path/to/secret; 32 + + owner = "did:plc:foo"; 33 + }; 34 + }; 35 + ```
+140 -41
docs/spindle/pipeline.md
··· 1 - # spindle pipeline manifest 1 + # spindle pipelines 2 + 3 + Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 + 5 + The fields are: 2 6 3 - Spindle pipelines are defined under the `.tangled/workflows` directory in a 4 - repo. Generally: 7 + - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 + - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 + - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 + - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 + - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 + - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 5 13 6 - * Pipelines are defined in YAML. 7 - * Dependencies can be specified from 8 - [Nixpkgs](https://search.nixos.org) or custom registries. 9 - * Environment variables can be set globally or per-step. 14 + ## Trigger 10 15 11 - Here's an example that uses all fields: 16 + The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 + 18 + - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 + - `push`: The workflow should run every time a commit is pushed to the repository. 20 + - `pull_request`: The workflow should run every time a pull request is made or updated. 21 + - `manual`: The workflow can be triggered manually. 22 + - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 + 24 + For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 12 25 13 26 ```yaml 14 - # build_and_test.yaml 15 27 when: 16 - - event: ["push", "pull_request"] 28 + - event: ["push", "manual"] 17 29 branch: ["main", "develop"] 18 - - event: ["manual"] 30 + - event: ["pull_request"] 31 + branch: ["main"] 32 + ``` 33 + 34 + ## Engine 35 + 36 + Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 37 + 38 + - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 39 + 40 + Example: 41 + 42 + ```yaml 43 + engine: "nixery" 44 + ``` 45 + 46 + ## Clone options 47 + 48 + When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 49 + 50 + - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 51 + - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 52 + - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 53 + 54 + The default settings are: 55 + 56 + ```yaml 57 + clone: 58 + skip: false 59 + depth: 1 60 + submodules: false 61 + ``` 62 + 63 + ## Dependencies 64 + 65 + Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 66 + 67 + Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 19 68 69 + ```yaml 20 70 dependencies: 21 - ## from nixpkgs 71 + # nixpkgs 22 72 nixpkgs: 23 73 - nodejs 24 - ## custom registry 25 - git+https://tangled.sh/@oppi.li/statix: 26 - - statix 74 + - go 75 + # custom registry 76 + git+https://tangled.sh/@example.com/my_pkg: 77 + - my_pkg 78 + ``` 79 + 80 + Now these dependencies are available to use in your workflow! 81 + 82 + ## Environment 83 + 84 + The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 85 + 86 + Example: 87 + 88 + ```yaml 89 + environment: 90 + GOOS: "linux" 91 + GOARCH: "arm64" 92 + NODE_ENV: "production" 93 + MY_ENV_VAR: "MY_ENV_VALUE" 94 + ``` 27 95 28 - steps: 29 - - name: "Install dependencies" 30 - command: "npm install" 31 - environment: 32 - NODE_ENV: "development" 33 - CI: "true" 96 + ## Steps 97 + 98 + The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 99 + 100 + - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 101 + - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 102 + - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 34 103 35 - - name: "Run linter" 36 - command: "npm run lint" 104 + Example: 37 105 38 - - name: "Run tests" 39 - command: "npm test" 106 + ```yaml 107 + steps: 108 + - name: "Build backend" 109 + command: "go build" 40 110 environment: 41 - NODE_ENV: "test" 42 - JEST_WORKERS: "2" 43 - 44 - - name: "Build application" 111 + GOOS: "darwin" 112 + GOARCH: "arm64" 113 + - name: "Build frontend" 45 114 command: "npm run build" 46 115 environment: 47 116 NODE_ENV: "production" 117 + ``` 48 118 49 - environment: 50 - BUILD_NUMBER: "123" 51 - GIT_BRANCH: "main" 119 + ## Complete workflow 52 120 53 - ## current repository is cloned and checked out at the target ref 54 - ## by default. 121 + ```yaml 122 + # .tangled/workflows/build.yml 123 + 124 + when: 125 + - event: ["push", "manual"] 126 + branch: ["main", "develop"] 127 + - event: ["pull_request"] 128 + branch: ["main"] 129 + 130 + engine: "nixery" 131 + 132 + # using the default values 55 133 clone: 56 134 skip: false 57 - depth: 50 58 - submodules: true 59 - ``` 135 + depth: 1 136 + submodules: false 137 + 138 + dependencies: 139 + # nixpkgs 140 + nixpkgs: 141 + - nodejs 142 + - go 143 + # custom registry 144 + git+https://tangled.sh/@example.com/my_pkg: 145 + - my_pkg 60 146 61 - ## git push options 147 + environment: 148 + GOOS: "linux" 149 + GOARCH: "arm64" 150 + NODE_ENV: "production" 151 + MY_ENV_VAR: "MY_ENV_VALUE" 62 152 63 - These are push options that can be used with the `--push-option (-o)` flag of git push: 153 + steps: 154 + - name: "Build backend" 155 + command: "go build" 156 + environment: 157 + GOOS: "darwin" 158 + GOARCH: "arm64" 159 + - name: "Build frontend" 160 + command: "npm run build" 161 + environment: 162 + NODE_ENV: "production" 163 + ``` 64 164 65 - - `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push. 66 - - `skip-ci`, `ci-skip`: skips triggering the CI pipeline. 165 + If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 21 } 22 22 23 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 25 if err != nil { 26 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 27 }
+3 -3
flake.lock
··· 26 26 ] 27 27 }, 28 28 "locked": { 29 - "lastModified": 1751702058, 30 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 31 31 "owner": "nix-community", 32 32 "repo": "gomod2nix", 33 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 34 34 "type": "github" 35 35 }, 36 36 "original": {
+57 -31
flake.nix
··· 106 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 107 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 108 108 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 109 + 110 + treefmt-wrapper = pkgs.treefmt.withConfig { 111 + settings.formatter = { 112 + alejandra = { 113 + command = pkgs.lib.getExe pkgs.alejandra; 114 + includes = ["*.nix"]; 115 + }; 116 + 117 + gofmt = { 118 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 119 + options = ["-w"]; 120 + includes = ["*.go"]; 121 + }; 122 + 123 + # prettier = let 124 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 125 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 126 + # ''; 127 + # in { 128 + # command = wrapper; 129 + # options = ["-w"]; 130 + # includes = ["*.html"]; 131 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 132 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 133 + # }; 134 + }; 135 + }; 109 136 }); 110 137 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 111 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 112 138 devShells = forAllSystems (system: let 113 139 pkgs = nixpkgsFor.${system}; 114 140 packages' = self.packages.${system}; ··· 129 155 pkgs.redis 130 156 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 131 157 packages'.lexgen 158 + packages'.treefmt-wrapper 132 159 ]; 133 160 shellHook = '' 134 161 mkdir -p appview/pages/static 135 162 # no preserve is needed because watch-tailwind will want to be able to overwrite 136 - cp -frv --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 163 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 137 164 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 138 165 ''; 139 166 env.CGO_ENABLED = 1; ··· 158 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 159 186 ''; 160 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 161 192 watch-appview = { 162 193 type = "app"; 163 194 program = toString (pkgs.writeShellScript "watch-appview" '' 164 195 echo "copying static files to appview/pages/static..." 165 - ${pkgs.coreutils}/bin/cp -frv --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 196 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 166 197 ${air-watcher "appview" ""}/bin/run 167 198 ''); 168 199 }; ··· 175 206 program = ''${tailwind-watcher}/bin/run''; 176 207 }; 177 208 vm = let 178 - system = 209 + guestSystem = 179 210 if pkgs.stdenv.hostPlatform.isAarch64 180 - then "aarch64" 181 - else "x86_64"; 182 - 183 - nixos-shell = pkgs.nixos-shell.overrideAttrs (old: { 184 - patches = 185 - (old.patches or []) 186 - ++ [ 187 - # https://github.com/Mic92/nixos-shell/pull/94 188 - (pkgs.fetchpatch { 189 - name = "fix-foreign-vm.patch"; 190 - url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch"; 191 - hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo="; 192 - }) 193 - ]; 194 - }); 211 + then "aarch64-linux" 212 + else "x86_64-linux"; 195 213 in { 196 214 type = "app"; 197 - program = toString (pkgs.writeShellScript "vm" '' 198 - ${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux 199 - ''); 215 + program = 216 + (pkgs.writeShellApplication { 217 + name = "launch-vm"; 218 + text = '' 219 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 220 + cd "$rootDir" 221 + 222 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 223 + 224 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 225 + exec ${pkgs.lib.getExe 226 + (import ./nix/vm.nix { 227 + inherit nixpkgs self; 228 + system = guestSystem; 229 + hostSystem = system; 230 + }).config.system.build.vm} 231 + ''; 232 + }) 233 + + /bin/launch-vm; 200 234 }; 201 235 gomod2nix = { 202 236 type = "app"; ··· 218 252 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 219 253 cd "$rootDir" 220 254 221 - rm api/tangled/* 255 + rm -f api/tangled/* 222 256 lexgen --build-file lexicon-build-config.json lexicons 223 257 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 224 258 ${pkgs.gotools}/bin/goimports -w api/tangled/* ··· 257 291 imports = [./nix/modules/spindle.nix]; 258 292 259 293 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 260 - }; 261 - nixosConfigurations.vm-x86_64 = import ./nix/vm.nix { 262 - inherit self nixpkgs; 263 - system = "x86_64-linux"; 264 - }; 265 - nixosConfigurations.vm-aarch64 = import ./nix/vm.nix { 266 - inherit self nixpkgs; 267 - system = "aarch64-linux"; 268 294 }; 269 295 }; 270 296 }
+5 -1
go.mod
··· 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 24 github.com/google/uuid v1.6.0 25 + github.com/gorilla/feeds v1.2.0 25 26 github.com/gorilla/sessions v1.4.0 26 27 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 27 28 github.com/hiddeco/sshsig v0.2.0 ··· 38 39 github.com/stretchr/testify v1.10.0 39 40 github.com/urfave/cli/v3 v3.3.3 40 41 github.com/whyrusleeping/cbor-gen v0.3.1 41 - github.com/yuin/goldmark v1.4.13 42 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.12 44 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 42 45 golang.org/x/crypto v0.40.0 43 46 golang.org/x/net v0.42.0 44 47 golang.org/x/sync v0.16.0 ··· 152 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 153 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 154 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 155 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 156 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 157 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+12 -1
go.sum
··· 79 79 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 80 80 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 81 81 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 82 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 82 83 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 83 84 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 84 85 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 173 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 174 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 175 176 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 177 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 178 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 176 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 177 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 178 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= ··· 423 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 424 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 425 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 429 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 430 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 + github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 + github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 426 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 427 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 428 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 429 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 430 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 431 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 + github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 + github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 432 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 433 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 434 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+83 -6
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 40 + font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 32 48 font-weight: normal; 33 49 font-style: italic; 34 50 font-display: swap; 35 51 } 36 52 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 65 + font-style: italic; 66 + font-display: swap; 67 + } 68 + 37 69 ::selection { 38 70 @apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white; 39 71 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 70 101 details summary::-webkit-details-marker { 71 102 display: none; 72 103 } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + } 73 108 } 74 109 75 110 @layer components { ··· 98 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 99 134 } 100 135 136 + .prose hr { 137 + @apply my-2; 138 + } 139 + 140 + .prose li:has(input) { 141 + @apply list-none; 142 + } 143 + 144 + .prose ul:has(input) { 145 + @apply pl-2; 146 + } 147 + 148 + .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 + } 151 + 152 + .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 + } 155 + 156 + .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 + } 159 + 160 + .prose a.footnote-backref { 161 + @apply no-underline; 162 + } 163 + 164 + .prose li { 165 + @apply my-0 py-0; 166 + } 167 + 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 170 + } 171 + 101 172 .prose img { 102 173 display: inline; 103 174 margin: 0; 104 175 vertical-align: middle; 176 + } 177 + 178 + .prose input { 179 + @apply inline-block my-0 mb-1 mx-1; 180 + } 181 + 182 + .prose input[type="checkbox"] { 183 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 105 184 } 106 185 } 107 186 @layer utilities { ··· 122 201 /* PreWrapper */ 123 202 .chroma { 124 203 color: #4c4f69; 125 - background-color: #eff1f5; 126 204 } 127 205 /* Error */ 128 206 .chroma .err { ··· 459 537 /* PreWrapper */ 460 538 .chroma { 461 539 color: #cad3f5; 462 - background-color: #24273a; 463 540 } 464 541 /* Error */ 465 542 .chroma .err {
+6 -4
jetstream/jetstream.go
··· 68 68 type processor func(context.Context, *models.Event) error 69 69 70 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 71 - // empty filter => all dids allowed 72 - if len(j.wantedDids) == 0 { 73 - return processFunc 74 - } 75 71 // since this closure references j.WantedDids; it should auto-update 76 72 // existing instances of the closure when j.WantedDids is mutated 77 73 return func(ctx context.Context, evt *models.Event) error { 74 + 75 + // empty filter => all dids allowed 76 + if len(j.wantedDids) == 0 { 77 + return processFunc(ctx, evt) 78 + } 79 + 78 80 if _, ok := j.wantedDids[evt.Did]; ok { 79 81 return processFunc(ctx, evt) 80 82 } else {
-336
knotclient/signer.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log" 12 - "net/http" 13 - "net/url" 14 - "time" 15 - 16 - "tangled.sh/tangled.sh/core/types" 17 - ) 18 - 19 - type SignerTransport struct { 20 - Secret string 21 - } 22 - 23 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 - timestamp := time.Now().Format(time.RFC3339) 25 - mac := hmac.New(sha256.New, []byte(s.Secret)) 26 - message := req.Method + req.URL.Path + timestamp 27 - mac.Write([]byte(message)) 28 - signature := hex.EncodeToString(mac.Sum(nil)) 29 - req.Header.Set("X-Signature", signature) 30 - req.Header.Set("X-Timestamp", timestamp) 31 - return http.DefaultTransport.RoundTrip(req) 32 - } 33 - 34 - type SignedClient struct { 35 - Secret string 36 - Url *url.URL 37 - client *http.Client 38 - } 39 - 40 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 - client := &http.Client{ 42 - Timeout: 5 * time.Second, 43 - Transport: SignerTransport{ 44 - Secret: secret, 45 - }, 46 - } 47 - 48 - scheme := "https" 49 - if dev { 50 - scheme = "http" 51 - } 52 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - signedClient := &SignedClient{ 58 - Secret: secret, 59 - client: client, 60 - Url: url, 61 - } 62 - 63 - return signedClient, nil 64 - } 65 - 66 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 - } 69 - 70 - func (s *SignedClient) Init(did string) (*http.Response, error) { 71 - const ( 72 - Method = "POST" 73 - Endpoint = "/init" 74 - ) 75 - 76 - body, _ := json.Marshal(map[string]any{ 77 - "did": did, 78 - }) 79 - 80 - req, err := s.newRequest(Method, Endpoint, body) 81 - if err != nil { 82 - return nil, err 83 - } 84 - 85 - return s.client.Do(req) 86 - } 87 - 88 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 - const ( 90 - Method = "PUT" 91 - Endpoint = "/repo/new" 92 - ) 93 - 94 - body, _ := json.Marshal(map[string]any{ 95 - "did": did, 96 - "name": repoName, 97 - "default_branch": defaultBranch, 98 - }) 99 - 100 - req, err := s.newRequest(Method, Endpoint, body) 101 - if err != nil { 102 - return nil, err 103 - } 104 - 105 - return s.client.Do(req) 106 - } 107 - 108 - func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 - const ( 110 - Method = "GET" 111 - ) 112 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 - 114 - req, err := s.newRequest(Method, endpoint, nil) 115 - if err != nil { 116 - return nil, err 117 - } 118 - 119 - resp, err := s.client.Do(req) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - var result types.RepoLanguageResponse 125 - if resp.StatusCode != http.StatusOK { 126 - log.Println("failed to calculate languages", resp.Status) 127 - return &types.RepoLanguageResponse{}, nil 128 - } 129 - 130 - body, err := io.ReadAll(resp.Body) 131 - if err != nil { 132 - return nil, err 133 - } 134 - 135 - err = json.Unmarshal(body, &result) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return &result, nil 141 - } 142 - 143 - func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 - const ( 145 - Method = "GET" 146 - ) 147 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": ownerDid, 151 - "source": source, 152 - "name": name, 153 - "hiddenref": hiddenRef, 154 - }) 155 - 156 - req, err := s.newRequest(Method, endpoint, body) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - return s.client.Do(req) 162 - } 163 - 164 - func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 - 170 - body, _ := json.Marshal(map[string]any{ 171 - "did": ownerDid, 172 - "source": source, 173 - "name": name, 174 - }) 175 - 176 - req, err := s.newRequest(Method, endpoint, body) 177 - if err != nil { 178 - return nil, err 179 - } 180 - 181 - return s.client.Do(req) 182 - } 183 - 184 - func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 - const ( 186 - Method = "POST" 187 - Endpoint = "/repo/fork" 188 - ) 189 - 190 - body, _ := json.Marshal(map[string]any{ 191 - "did": ownerDid, 192 - "source": source, 193 - "name": name, 194 - }) 195 - 196 - req, err := s.newRequest(Method, Endpoint, body) 197 - if err != nil { 198 - return nil, err 199 - } 200 - 201 - return s.client.Do(req) 202 - } 203 - 204 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 - const ( 206 - Method = "DELETE" 207 - Endpoint = "/repo" 208 - ) 209 - 210 - body, _ := json.Marshal(map[string]any{ 211 - "did": did, 212 - "name": repoName, 213 - }) 214 - 215 - req, err := s.newRequest(Method, Endpoint, body) 216 - if err != nil { 217 - return nil, err 218 - } 219 - 220 - return s.client.Do(req) 221 - } 222 - 223 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 - const ( 225 - Method = "PUT" 226 - Endpoint = "/member/add" 227 - ) 228 - 229 - body, _ := json.Marshal(map[string]any{ 230 - "did": did, 231 - }) 232 - 233 - req, err := s.newRequest(Method, Endpoint, body) 234 - if err != nil { 235 - return nil, err 236 - } 237 - 238 - return s.client.Do(req) 239 - } 240 - 241 - func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 - const ( 243 - Method = "PUT" 244 - ) 245 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 - 247 - body, _ := json.Marshal(map[string]any{ 248 - "branch": branch, 249 - }) 250 - 251 - req, err := s.newRequest(Method, endpoint, body) 252 - if err != nil { 253 - return nil, err 254 - } 255 - 256 - return s.client.Do(req) 257 - } 258 - 259 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 - const ( 261 - Method = "POST" 262 - ) 263 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 - 265 - body, _ := json.Marshal(map[string]any{ 266 - "did": memberDid, 267 - }) 268 - 269 - req, err := s.newRequest(Method, endpoint, body) 270 - if err != nil { 271 - return nil, err 272 - } 273 - 274 - return s.client.Do(req) 275 - } 276 - 277 - func (s *SignedClient) Merge( 278 - patch []byte, 279 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 - ) (*http.Response, error) { 281 - const ( 282 - Method = "POST" 283 - ) 284 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 - 286 - mr := types.MergeRequest{ 287 - Branch: branch, 288 - CommitMessage: commitMessage, 289 - CommitBody: commitBody, 290 - AuthorName: authorName, 291 - AuthorEmail: authorEmail, 292 - Patch: string(patch), 293 - } 294 - 295 - body, _ := json.Marshal(mr) 296 - 297 - req, err := s.newRequest(Method, endpoint, body) 298 - if err != nil { 299 - return nil, err 300 - } 301 - 302 - return s.client.Do(req) 303 - } 304 - 305 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 - const ( 307 - Method = "POST" 308 - ) 309 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 - 311 - body, _ := json.Marshal(map[string]any{ 312 - "patch": string(patch), 313 - "branch": branch, 314 - }) 315 - 316 - req, err := s.newRequest(Method, endpoint, body) 317 - if err != nil { 318 - return nil, err 319 - } 320 - 321 - return s.client.Do(req) 322 - } 323 - 324 - func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 - const ( 326 - Method = "POST" 327 - ) 328 - endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 - 330 - req, err := s.newRequest(Method, endpoint, nil) 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return s.client.Do(req) 336 - }
+35
knotclient/unsigned.go
··· 248 248 249 249 return &formatPatchResponse, nil 250 250 } 251 + 252 + func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 + const ( 254 + Method = "GET" 255 + ) 256 + endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 + 258 + req, err := s.newRequest(Method, endpoint, nil, nil) 259 + if err != nil { 260 + return nil, err 261 + } 262 + 263 + resp, err := s.client.Do(req) 264 + if err != nil { 265 + return nil, err 266 + } 267 + 268 + var result types.RepoLanguageResponse 269 + if resp.StatusCode != http.StatusOK { 270 + log.Println("failed to calculate languages", resp.Status) 271 + return &types.RepoLanguageResponse{}, nil 272 + } 273 + 274 + body, err := io.ReadAll(resp.Body) 275 + if err != nil { 276 + return nil, err 277 + } 278 + 279 + err = json.Unmarshal(body, &result) 280 + if err != nil { 281 + return nil, err 282 + } 283 + 284 + return &result, nil 285 + }
+1 -1
knotserver/config/config.go
··· 17 17 type Server struct { 18 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 - Secret string `env:"SECRET, required"` 21 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 22 21 Hostname string `env:"HOSTNAME, required"` 23 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 + Owner string `env:"OWNER, required"` 24 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 25 26 26 // This disables signature verification so use with caution.
+14 -10
knotserver/db/init.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 28 31 32 + _, err = db.Exec(` 29 33 create table if not exists known_dids ( 30 34 did text primary key 31 35 );
+8 -10
knotserver/git/fork.go
··· 10 10 ) 11 11 12 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 19 15 return fmt.Errorf("failed to bare clone repository: %w", err) 20 16 } 21 17 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 24 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 21 } 26 22 27 23 return nil 28 24 } 29 25 30 - func (g *GitRepo) Sync(branch string) error { 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 31 29 fetchOpts := &git.FetchOptions{ 32 30 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 34 32 }, 35 33 } 36 34
+28 -22
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String())) ··· 138 145 } 139 146 140 147 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 141 - var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem 148 + var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount 142 149 for e, v := range m.CommitCount.ByEmail { 143 - byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{ 150 + byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{ 144 151 Email: e, 145 152 Count: int64(v), 146 153 }) 147 154 } 148 155 149 - var langs []*tangled.GitRefUpdate_Pair 156 + var langs []*tangled.GitRefUpdate_IndividualLanguageSize 150 157 for lang, size := range m.LangBreakdown { 151 - langs = append(langs, &tangled.GitRefUpdate_Pair{ 158 + langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{ 152 159 Lang: lang, 153 160 Size: size, 154 161 }) 155 162 } 156 - langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 157 - Inputs: langs, 158 - } 159 163 160 164 return tangled.GitRefUpdate_Meta{ 161 - CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 165 + CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{ 162 166 ByEmail: byEmail, 163 167 }, 164 - IsDefaultRef: m.IsDefaultRef, 165 - LangBreakdown: langBreakdown, 168 + IsDefaultRef: m.IsDefaultRef, 169 + LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{ 170 + Inputs: langs, 171 + }, 166 172 } 167 173 }
+5
knotserver/git.go
··· 129 129 // If the appview gave us the repository owner's handle we can attempt to 130 130 // construct the correct ssh url. 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 132 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 134 hostname := d.c.Server.Hostname 134 135 if strings.Contains(hostname, ":") { 135 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 136 141 } 137 142 138 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+1008 -150
knotserver/handler.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 + "compress/gzip" 4 5 "context" 6 + "crypto/sha256" 7 + "encoding/json" 8 + "errors" 5 9 "fmt" 6 - "log/slog" 10 + "log" 7 11 "net/http" 8 - "runtime/debug" 12 + "net/url" 13 + "path/filepath" 14 + "strconv" 15 + "strings" 16 + "sync" 17 + "time" 9 18 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 + "github.com/gliderlabs/ssh" 10 21 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 22 + "github.com/go-git/go-git/v5/plumbing" 23 + "github.com/go-git/go-git/v5/plumbing/object" 14 24 "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/knotserver/git" 26 + "tangled.sh/tangled.sh/core/types" 19 27 ) 20 28 21 - type Handle struct { 22 - c *config.Config 23 - db *db.DB 24 - jc *jetstream.JetstreamClient 25 - e *rbac.Enforcer 26 - l *slog.Logger 27 - n *notifier.Notifier 28 - resolver *idresolver.Resolver 29 + func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 + } 32 + 33 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 + w.Header().Set("Content-Type", "application/json") 35 + 36 + capabilities := map[string]any{ 37 + "pull_requests": map[string]any{ 38 + "format_patch": true, 39 + "patch_submissions": true, 40 + "branch_submissions": true, 41 + "fork_submissions": true, 42 + }, 43 + "xrpc": true, 44 + } 29 45 30 - // init is a channel that is closed when the knot has been initailized 31 - // i.e. when the first user (knot owner) has been added. 32 - init chan struct{} 33 - knotInitialized bool 46 + jsonData, err := json.Marshal(capabilities) 47 + if err != nil { 48 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 + return 50 + } 51 + 52 + w.Write(jsonData) 34 53 } 35 54 36 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 37 - r := chi.NewRouter() 55 + func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 + l := h.l.With("path", path, "handler", "RepoIndex") 58 + ref := chi.URLParam(r, "ref") 59 + ref, _ = url.PathUnescape(ref) 60 + 61 + gr, err := git.Open(path, ref) 62 + if err != nil { 63 + plain, err2 := git.PlainOpen(path) 64 + if err2 != nil { 65 + l.Error("opening repo", "error", err2.Error()) 66 + notFound(w) 67 + return 68 + } 69 + branches, _ := plain.Branches() 38 70 39 - h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - resolver: idresolver.DefaultResolver(), 47 - init: make(chan struct{}), 71 + log.Println(err) 72 + 73 + if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 + resp := types.RepoIndexResponse{ 75 + IsEmpty: true, 76 + Branches: branches, 77 + } 78 + writeJSON(w, resp) 79 + return 80 + } else { 81 + l.Error("opening repo", "error", err.Error()) 82 + notFound(w) 83 + return 84 + } 48 85 } 49 86 50 - err := e.AddKnot(rbac.ThisServer) 87 + var ( 88 + commits []*object.Commit 89 + total int 90 + branches []types.Branch 91 + files []types.NiceTree 92 + tags []object.Tag 93 + ) 94 + 95 + var wg sync.WaitGroup 96 + errorsCh := make(chan error, 5) 97 + 98 + wg.Add(1) 99 + go func() { 100 + defer wg.Done() 101 + cs, err := gr.Commits(0, 60) 102 + if err != nil { 103 + errorsCh <- fmt.Errorf("commits: %w", err) 104 + return 105 + } 106 + commits = cs 107 + }() 108 + 109 + wg.Add(1) 110 + go func() { 111 + defer wg.Done() 112 + t, err := gr.TotalCommits() 113 + if err != nil { 114 + errorsCh <- fmt.Errorf("calculating total: %w", err) 115 + return 116 + } 117 + total = t 118 + }() 119 + 120 + wg.Add(1) 121 + go func() { 122 + defer wg.Done() 123 + bs, err := gr.Branches() 124 + if err != nil { 125 + errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 + return 127 + } 128 + branches = bs 129 + }() 130 + 131 + wg.Add(1) 132 + go func() { 133 + defer wg.Done() 134 + ts, err := gr.Tags() 135 + if err != nil { 136 + errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 + return 138 + } 139 + tags = ts 140 + }() 141 + 142 + wg.Add(1) 143 + go func() { 144 + defer wg.Done() 145 + fs, err := gr.FileTree(r.Context(), "") 146 + if err != nil { 147 + errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 + return 149 + } 150 + files = fs 151 + }() 152 + 153 + wg.Wait() 154 + close(errorsCh) 155 + 156 + // show any errors 157 + for err := range errorsCh { 158 + l.Error("loading repo", "error", err.Error()) 159 + writeError(w, err.Error(), http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + rtags := []*types.TagReference{} 164 + for _, tag := range tags { 165 + var target *object.Tag 166 + if tag.Target != plumbing.ZeroHash { 167 + target = &tag 168 + } 169 + tr := types.TagReference{ 170 + Tag: target, 171 + } 172 + 173 + tr.Reference = types.Reference{ 174 + Name: tag.Name, 175 + Hash: tag.Hash.String(), 176 + } 177 + 178 + if tag.Message != "" { 179 + tr.Message = tag.Message 180 + } 181 + 182 + rtags = append(rtags, &tr) 183 + } 184 + 185 + var readmeContent string 186 + var readmeFile string 187 + for _, readme := range h.c.Repo.Readme { 188 + content, _ := gr.FileContent(readme) 189 + if len(content) > 0 { 190 + readmeContent = string(content) 191 + readmeFile = readme 192 + } 193 + } 194 + 195 + if ref == "" { 196 + mainBranch, err := gr.FindMainBranch() 197 + if err != nil { 198 + writeError(w, err.Error(), http.StatusInternalServerError) 199 + l.Error("finding main branch", "error", err.Error()) 200 + return 201 + } 202 + ref = mainBranch 203 + } 204 + 205 + resp := types.RepoIndexResponse{ 206 + IsEmpty: false, 207 + Ref: ref, 208 + Commits: commits, 209 + Description: getDescription(path), 210 + Readme: readmeContent, 211 + ReadmeFileName: readmeFile, 212 + Files: files, 213 + Branches: branches, 214 + Tags: rtags, 215 + TotalCommits: total, 216 + } 217 + 218 + writeJSON(w, resp) 219 + } 220 + 221 + func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 + treePath := chi.URLParam(r, "*") 223 + ref := chi.URLParam(r, "ref") 224 + ref, _ = url.PathUnescape(ref) 225 + 226 + l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 + 228 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 + gr, err := git.Open(path, ref) 51 230 if err != nil { 52 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 231 + notFound(w) 232 + return 53 233 } 54 234 55 - err = h.jc.StartJetstream(ctx, h.processMessages) 235 + files, err := gr.FileTree(r.Context(), treePath) 56 236 if err != nil { 57 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 237 + writeError(w, err.Error(), http.StatusInternalServerError) 238 + l.Error("file tree", "error", err.Error()) 239 + return 58 240 } 59 241 60 - // Check if the knot knows about any Dids; 61 - // if it does, it is already initialized and we can repopulate the 62 - // Jetstream subscriptions. 63 - dids, err := db.GetAllDids() 242 + resp := types.RepoTreeResponse{ 243 + Ref: ref, 244 + Parent: treePath, 245 + Description: getDescription(path), 246 + DotDot: filepath.Dir(treePath), 247 + Files: files, 248 + } 249 + 250 + writeJSON(w, resp) 251 + } 252 + 253 + func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 + treePath := chi.URLParam(r, "*") 255 + ref := chi.URLParam(r, "ref") 256 + ref, _ = url.PathUnescape(ref) 257 + 258 + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 + 260 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 + gr, err := git.Open(path, ref) 64 262 if err != nil { 65 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 263 + notFound(w) 264 + return 265 + } 266 + 267 + contents, err := gr.RawContent(treePath) 268 + if err != nil { 269 + writeError(w, err.Error(), http.StatusBadRequest) 270 + l.Error("file content", "error", err.Error()) 271 + return 272 + } 273 + 274 + mimeType := http.DetectContentType(contents) 275 + 276 + // exception for svg 277 + if filepath.Ext(treePath) == ".svg" { 278 + mimeType = "image/svg+xml" 66 279 } 67 280 68 - if len(dids) > 0 { 69 - h.knotInitialized = true 70 - close(h.init) 71 - for _, d := range dids { 72 - h.jc.AddDid(d) 281 + contentHash := sha256.Sum256(contents) 282 + eTag := fmt.Sprintf("\"%x\"", contentHash) 283 + 284 + // allow image, video, and text/plain files to be served directly 285 + switch { 286 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 + w.WriteHeader(http.StatusNotModified) 289 + return 73 290 } 291 + w.Header().Set("ETag", eTag) 292 + 293 + case strings.HasPrefix(mimeType, "text/plain"): 294 + w.Header().Set("Cache-Control", "public, no-cache") 295 + 296 + default: 297 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 + return 74 300 } 75 301 76 - r.Get("/", h.Index) 77 - r.Get("/capabilities", h.Capabilities) 78 - r.Get("/version", h.Version) 79 - r.Route("/{did}", func(r chi.Router) { 80 - // Repo routes 81 - r.Route("/{name}", func(r chi.Router) { 82 - r.Route("/collaborator", func(r chi.Router) { 83 - r.Use(h.VerifySignature) 84 - r.Post("/add", h.AddRepoCollaborator) 85 - }) 302 + w.Header().Set("Content-Type", mimeType) 303 + w.Write(contents) 304 + } 305 + 306 + func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 + treePath := chi.URLParam(r, "*") 308 + ref := chi.URLParam(r, "ref") 309 + ref, _ = url.PathUnescape(ref) 310 + 311 + l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 + 313 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 + gr, err := git.Open(path, ref) 315 + if err != nil { 316 + notFound(w) 317 + return 318 + } 319 + 320 + var isBinaryFile bool = false 321 + contents, err := gr.FileContent(treePath) 322 + if errors.Is(err, git.ErrBinaryFile) { 323 + isBinaryFile = true 324 + } else if errors.Is(err, object.ErrFileNotFound) { 325 + notFound(w) 326 + return 327 + } else if err != nil { 328 + writeError(w, err.Error(), http.StatusInternalServerError) 329 + return 330 + } 331 + 332 + bytes := []byte(contents) 333 + // safe := string(sanitize(bytes)) 334 + sizeHint := len(bytes) 335 + 336 + resp := types.RepoBlobResponse{ 337 + Ref: ref, 338 + Contents: string(bytes), 339 + Path: treePath, 340 + IsBinary: isBinaryFile, 341 + SizeHint: uint64(sizeHint), 342 + } 343 + 344 + h.showFile(resp, w, l) 345 + } 346 + 347 + func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 + name := chi.URLParam(r, "name") 349 + file := chi.URLParam(r, "file") 86 350 87 - r.Route("/languages", func(r chi.Router) { 88 - r.With(h.VerifySignature) 89 - r.Get("/", h.RepoLanguages) 90 - r.Get("/{ref}", h.RepoLanguages) 91 - }) 351 + l := h.l.With("handler", "Archive", "name", name, "file", file) 92 352 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/info/refs", h.InfoRefs) 95 - r.Post("/git-upload-pack", h.UploadPack) 96 - r.Post("/git-receive-pack", h.ReceivePack) 97 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 353 + // TODO: extend this to add more files compression (e.g.: xz) 354 + if !strings.HasSuffix(file, ".tar.gz") { 355 + notFound(w) 356 + return 357 + } 98 358 99 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 359 + ref := strings.TrimSuffix(file, ".tar.gz") 100 360 101 - r.Route("/merge", func(r chi.Router) { 102 - r.With(h.VerifySignature) 103 - r.Post("/", h.Merge) 104 - r.Post("/check", h.MergeCheck) 105 - }) 361 + unescapedRef, err := url.PathUnescape(ref) 362 + if err != nil { 363 + notFound(w) 364 + return 365 + } 366 + 367 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 + 369 + // This allows the browser to use a proper name for the file when 370 + // downloading 371 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 + setContentDisposition(w, filename) 373 + setGZipMIME(w) 374 + 375 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 + gr, err := git.Open(path, unescapedRef) 377 + if err != nil { 378 + notFound(w) 379 + return 380 + } 381 + 382 + gw := gzip.NewWriter(w) 383 + defer gw.Close() 384 + 385 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 + err = gr.WriteTar(gw, prefix) 387 + if err != nil { 388 + // once we start writing to the body we can't report error anymore 389 + // so we are only left with printing the error. 390 + l.Error("writing tar file", "error", err.Error()) 391 + return 392 + } 393 + 394 + err = gw.Flush() 395 + if err != nil { 396 + // once we start writing to the body we can't report error anymore 397 + // so we are only left with printing the error. 398 + l.Error("flushing?", "error", err.Error()) 399 + return 400 + } 401 + } 106 402 107 - r.Route("/tree/{ref}", func(r chi.Router) { 108 - r.Get("/", h.RepoIndex) 109 - r.Get("/*", h.RepoTree) 110 - }) 403 + func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 + ref := chi.URLParam(r, "ref") 405 + ref, _ = url.PathUnescape(ref) 111 406 112 - r.Route("/blob/{ref}", func(r chi.Router) { 113 - r.Get("/*", h.Blob) 114 - }) 407 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 115 408 116 - r.Route("/raw/{ref}", func(r chi.Router) { 117 - r.Get("/*", h.BlobRaw) 118 - }) 409 + l := h.l.With("handler", "Log", "ref", ref, "path", path) 119 410 120 - r.Get("/log/{ref}", h.Log) 121 - r.Get("/archive/{file}", h.Archive) 122 - r.Get("/commit/{ref}", h.Diff) 123 - r.Get("/tags", h.Tags) 124 - r.Route("/branches", func(r chi.Router) { 125 - r.Get("/", h.Branches) 126 - r.Get("/{branch}", h.Branch) 127 - r.Route("/default", func(r chi.Router) { 128 - r.Get("/", h.DefaultBranch) 129 - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 130 - }) 131 - }) 132 - }) 133 - }) 411 + gr, err := git.Open(path, ref) 412 + if err != nil { 413 + notFound(w) 414 + return 415 + } 134 416 135 - // xrpc apis 136 - r.Mount("/xrpc", h.XrpcRouter()) 417 + // Get page parameters 418 + page := 1 419 + pageSize := 30 137 420 138 - // Create a new repository. 139 - r.Route("/repo", func(r chi.Router) { 140 - r.Use(h.VerifySignature) 141 - r.Put("/new", h.NewRepo) 142 - r.Delete("/", h.RemoveRepo) 143 - r.Route("/fork", func(r chi.Router) { 144 - r.Post("/", h.RepoFork) 145 - r.Post("/sync/{branch}", h.RepoForkSync) 146 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 147 - }) 148 - }) 421 + if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 + if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 + page = p 424 + } 425 + } 149 426 150 - r.Route("/member", func(r chi.Router) { 151 - r.Use(h.VerifySignature) 152 - r.Put("/add", h.AddMember) 153 - }) 427 + if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 + if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 + pageSize = ps 430 + } 431 + } 154 432 155 - // Socket that streams git oplogs 156 - r.Get("/events", h.Events) 433 + // convert to offset/limit 434 + offset := (page - 1) * pageSize 435 + limit := pageSize 157 436 158 - // Initialize the knot with an owner and public key. 159 - r.With(h.VerifySignature).Post("/init", h.Init) 437 + commits, err := gr.Commits(offset, limit) 438 + if err != nil { 439 + writeError(w, err.Error(), http.StatusInternalServerError) 440 + l.Error("fetching commits", "error", err.Error()) 441 + return 442 + } 160 443 161 - // Health check. Used for two-way verification with appview. 162 - r.With(h.VerifySignature).Get("/health", h.Health) 444 + total := len(commits) 163 445 164 - // All public keys on the knot. 165 - r.Get("/keys", h.Keys) 446 + resp := types.RepoLogResponse{ 447 + Commits: commits, 448 + Ref: ref, 449 + Description: getDescription(path), 450 + Log: true, 451 + Total: total, 452 + Page: page, 453 + PerPage: pageSize, 454 + } 166 455 167 - return r, nil 456 + writeJSON(w, resp) 168 457 } 169 458 170 - func (h *Handle) XrpcRouter() http.Handler { 171 - logger := tlog.New("knots") 459 + func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 + ref := chi.URLParam(r, "ref") 461 + ref, _ = url.PathUnescape(ref) 172 462 173 - xrpc := &xrpc.Xrpc{ 174 - Config: h.c, 175 - Db: h.db, 176 - Ingester: h.jc, 177 - Enforcer: h.e, 178 - Logger: logger, 179 - Notifier: h.n, 180 - Resolver: h.resolver, 463 + l := h.l.With("handler", "Diff", "ref", ref) 464 + 465 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 + gr, err := git.Open(path, ref) 467 + if err != nil { 468 + notFound(w) 469 + return 181 470 } 182 - return xrpc.Router() 471 + 472 + diff, err := gr.Diff() 473 + if err != nil { 474 + writeError(w, err.Error(), http.StatusInternalServerError) 475 + l.Error("getting diff", "error", err.Error()) 476 + return 477 + } 478 + 479 + resp := types.RepoCommitResponse{ 480 + Ref: ref, 481 + Diff: diff, 482 + } 483 + 484 + writeJSON(w, resp) 183 485 } 184 486 185 - // version is set during build time. 186 - var version string 487 + func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 + l := h.l.With("handler", "Refs") 187 490 188 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 189 - if version == "" { 190 - info, ok := debug.ReadBuildInfo() 191 - if !ok { 192 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 491 + gr, err := git.Open(path, "") 492 + if err != nil { 493 + notFound(w) 494 + return 495 + } 496 + 497 + tags, err := gr.Tags() 498 + if err != nil { 499 + // Non-fatal, we *should* have at least one branch to show. 500 + l.Warn("getting tags", "error", err.Error()) 501 + } 502 + 503 + rtags := []*types.TagReference{} 504 + for _, tag := range tags { 505 + var target *object.Tag 506 + if tag.Target != plumbing.ZeroHash { 507 + target = &tag 508 + } 509 + tr := types.TagReference{ 510 + Tag: target, 511 + } 512 + 513 + tr.Reference = types.Reference{ 514 + Name: tag.Name, 515 + Hash: tag.Hash.String(), 516 + } 517 + 518 + if tag.Message != "" { 519 + tr.Message = tag.Message 520 + } 521 + 522 + rtags = append(rtags, &tr) 523 + } 524 + 525 + resp := types.RepoTagsResponse{ 526 + Tags: rtags, 527 + } 528 + 529 + writeJSON(w, resp) 530 + } 531 + 532 + func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 + 535 + gr, err := git.PlainOpen(path) 536 + if err != nil { 537 + notFound(w) 538 + return 539 + } 540 + 541 + branches, _ := gr.Branches() 542 + 543 + resp := types.RepoBranchesResponse{ 544 + Branches: branches, 545 + } 546 + 547 + writeJSON(w, resp) 548 + } 549 + 550 + func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 + branchName := chi.URLParam(r, "branch") 553 + branchName, _ = url.PathUnescape(branchName) 554 + 555 + l := h.l.With("handler", "Branch") 556 + 557 + gr, err := git.PlainOpen(path) 558 + if err != nil { 559 + notFound(w) 560 + return 561 + } 562 + 563 + ref, err := gr.Branch(branchName) 564 + if err != nil { 565 + l.Error("getting branch", "error", err.Error()) 566 + writeError(w, err.Error(), http.StatusInternalServerError) 567 + return 568 + } 569 + 570 + commit, err := gr.Commit(ref.Hash()) 571 + if err != nil { 572 + l.Error("getting commit object", "error", err.Error()) 573 + writeError(w, err.Error(), http.StatusInternalServerError) 574 + return 575 + } 576 + 577 + defaultBranch, err := gr.FindMainBranch() 578 + isDefault := false 579 + if err != nil { 580 + l.Error("getting default branch", "error", err.Error()) 581 + // do not quit though 582 + } else if defaultBranch == branchName { 583 + isDefault = true 584 + } 585 + 586 + resp := types.RepoBranchResponse{ 587 + Branch: types.Branch{ 588 + Reference: types.Reference{ 589 + Name: ref.Name().Short(), 590 + Hash: ref.Hash().String(), 591 + }, 592 + Commit: commit, 593 + IsDefault: isDefault, 594 + }, 595 + } 596 + 597 + writeJSON(w, resp) 598 + } 599 + 600 + func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 + l := h.l.With("handler", "Keys") 602 + 603 + switch r.Method { 604 + case http.MethodGet: 605 + keys, err := h.db.GetAllPublicKeys() 606 + if err != nil { 607 + writeError(w, err.Error(), http.StatusInternalServerError) 608 + l.Error("getting public keys", "error", err.Error()) 193 609 return 194 610 } 195 611 196 - var modVer string 197 - for _, mod := range info.Deps { 198 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 199 - version = mod.Version 200 - break 201 - } 612 + data := make([]map[string]any, 0) 613 + for _, key := range keys { 614 + j := key.JSON() 615 + data = append(data, j) 202 616 } 617 + writeJSON(w, data) 618 + return 203 619 204 - if modVer == "" { 205 - version = "unknown" 620 + case http.MethodPut: 621 + pk := db.PublicKey{} 622 + if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 + writeError(w, "invalid request body", http.StatusBadRequest) 624 + return 206 625 } 626 + 627 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 + if err != nil { 629 + writeError(w, "invalid pubkey", http.StatusBadRequest) 630 + } 631 + 632 + if err := h.db.AddPublicKey(pk); err != nil { 633 + writeError(w, err.Error(), http.StatusInternalServerError) 634 + l.Error("adding public key", "error", err.Error()) 635 + return 636 + } 637 + 638 + w.WriteHeader(http.StatusNoContent) 639 + return 640 + } 641 + } 642 + 643 + // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 + // l := h.l.With("handler", "RepoForkSync") 645 + // 646 + // data := struct { 647 + // Did string `json:"did"` 648 + // Source string `json:"source"` 649 + // Name string `json:"name,omitempty"` 650 + // HiddenRef string `json:"hiddenref"` 651 + // }{} 652 + // 653 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 + // writeError(w, "invalid request body", http.StatusBadRequest) 655 + // return 656 + // } 657 + // 658 + // did := data.Did 659 + // source := data.Source 660 + // 661 + // if did == "" || source == "" { 662 + // l.Error("invalid request body, empty did or name") 663 + // w.WriteHeader(http.StatusBadRequest) 664 + // return 665 + // } 666 + // 667 + // var name string 668 + // if data.Name != "" { 669 + // name = data.Name 670 + // } else { 671 + // name = filepath.Base(source) 672 + // } 673 + // 674 + // branch := chi.URLParam(r, "branch") 675 + // branch, _ = url.PathUnescape(branch) 676 + // 677 + // relativeRepoPath := filepath.Join(did, name) 678 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 + // 680 + // gr, err := git.PlainOpen(repoPath) 681 + // if err != nil { 682 + // log.Println(err) 683 + // notFound(w) 684 + // return 685 + // } 686 + // 687 + // forkCommit, err := gr.ResolveRevision(branch) 688 + // if err != nil { 689 + // l.Error("error resolving ref revision", "msg", err.Error()) 690 + // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 + // return 692 + // } 693 + // 694 + // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 + // if err != nil { 696 + // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 + // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 + // return 699 + // } 700 + // 701 + // status := types.UpToDate 702 + // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 + // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 + // if err != nil { 705 + // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 + // return 707 + // } 708 + // 709 + // if isAncestor { 710 + // status = types.FastForwardable 711 + // } else { 712 + // status = types.Conflict 713 + // } 714 + // } 715 + // 716 + // w.Header().Set("Content-Type", "application/json") 717 + // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 + // } 719 + 720 + func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 + ref := chi.URLParam(r, "ref") 723 + ref, _ = url.PathUnescape(ref) 724 + 725 + l := h.l.With("handler", "RepoLanguages") 726 + 727 + gr, err := git.Open(repoPath, ref) 728 + if err != nil { 729 + l.Error("opening repo", "error", err.Error()) 730 + notFound(w) 731 + return 207 732 } 208 733 209 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 - fmt.Fprintf(w, "knotserver/%s", version) 734 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 + defer cancel() 736 + 737 + sizes, err := gr.AnalyzeLanguages(ctx) 738 + if err != nil { 739 + l.Error("failed to analyze languages", "error", err.Error()) 740 + writeError(w, err.Error(), http.StatusNoContent) 741 + return 742 + } 743 + 744 + resp := types.RepoLanguageResponse{Languages: sizes} 745 + 746 + writeJSON(w, resp) 747 + } 748 + 749 + // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 + // l := h.l.With("handler", "RepoForkSync") 751 + // 752 + // data := struct { 753 + // Did string `json:"did"` 754 + // Source string `json:"source"` 755 + // Name string `json:"name,omitempty"` 756 + // }{} 757 + // 758 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 + // writeError(w, "invalid request body", http.StatusBadRequest) 760 + // return 761 + // } 762 + // 763 + // did := data.Did 764 + // source := data.Source 765 + // 766 + // if did == "" || source == "" { 767 + // l.Error("invalid request body, empty did or name") 768 + // w.WriteHeader(http.StatusBadRequest) 769 + // return 770 + // } 771 + // 772 + // var name string 773 + // if data.Name != "" { 774 + // name = data.Name 775 + // } else { 776 + // name = filepath.Base(source) 777 + // } 778 + // 779 + // branch := chi.URLParam(r, "branch") 780 + // branch, _ = url.PathUnescape(branch) 781 + // 782 + // relativeRepoPath := filepath.Join(did, name) 783 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 + // 785 + // gr, err := git.Open(repoPath, branch) 786 + // if err != nil { 787 + // log.Println(err) 788 + // notFound(w) 789 + // return 790 + // } 791 + // 792 + // err = gr.Sync() 793 + // if err != nil { 794 + // l.Error("error syncing repo fork", "error", err.Error()) 795 + // writeError(w, err.Error(), http.StatusInternalServerError) 796 + // return 797 + // } 798 + // 799 + // w.WriteHeader(http.StatusNoContent) 800 + // } 801 + 802 + // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 + // l := h.l.With("handler", "RepoFork") 804 + // 805 + // data := struct { 806 + // Did string `json:"did"` 807 + // Source string `json:"source"` 808 + // Name string `json:"name,omitempty"` 809 + // }{} 810 + // 811 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 + // writeError(w, "invalid request body", http.StatusBadRequest) 813 + // return 814 + // } 815 + // 816 + // did := data.Did 817 + // source := data.Source 818 + // 819 + // if did == "" || source == "" { 820 + // l.Error("invalid request body, empty did or name") 821 + // w.WriteHeader(http.StatusBadRequest) 822 + // return 823 + // } 824 + // 825 + // var name string 826 + // if data.Name != "" { 827 + // name = data.Name 828 + // } else { 829 + // name = filepath.Base(source) 830 + // } 831 + // 832 + // relativeRepoPath := filepath.Join(did, name) 833 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 + // 835 + // err := git.Fork(repoPath, source) 836 + // if err != nil { 837 + // l.Error("forking repo", "error", err.Error()) 838 + // writeError(w, err.Error(), http.StatusInternalServerError) 839 + // return 840 + // } 841 + // 842 + // // add perms for this user to access the repo 843 + // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 + // if err != nil { 845 + // l.Error("adding repo permissions", "error", err.Error()) 846 + // writeError(w, err.Error(), http.StatusInternalServerError) 847 + // return 848 + // } 849 + // 850 + // hook.SetupRepo( 851 + // hook.Config( 852 + // hook.WithScanPath(h.c.Repo.ScanPath), 853 + // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 + // ), 855 + // repoPath, 856 + // ) 857 + // 858 + // w.WriteHeader(http.StatusNoContent) 859 + // } 860 + 861 + // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 + // l := h.l.With("handler", "RemoveRepo") 863 + // 864 + // data := struct { 865 + // Did string `json:"did"` 866 + // Name string `json:"name"` 867 + // }{} 868 + // 869 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 + // writeError(w, "invalid request body", http.StatusBadRequest) 871 + // return 872 + // } 873 + // 874 + // did := data.Did 875 + // name := data.Name 876 + // 877 + // if did == "" || name == "" { 878 + // l.Error("invalid request body, empty did or name") 879 + // w.WriteHeader(http.StatusBadRequest) 880 + // return 881 + // } 882 + // 883 + // relativeRepoPath := filepath.Join(did, name) 884 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 + // err := os.RemoveAll(repoPath) 886 + // if err != nil { 887 + // l.Error("removing repo", "error", err.Error()) 888 + // writeError(w, err.Error(), http.StatusInternalServerError) 889 + // return 890 + // } 891 + // 892 + // w.WriteHeader(http.StatusNoContent) 893 + // 894 + // } 895 + 896 + // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 + // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 + // 899 + // data := types.MergeRequest{} 900 + // 901 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 + // writeError(w, err.Error(), http.StatusBadRequest) 903 + // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 + // return 905 + // } 906 + // 907 + // mo := &git.MergeOptions{ 908 + // AuthorName: data.AuthorName, 909 + // AuthorEmail: data.AuthorEmail, 910 + // CommitBody: data.CommitBody, 911 + // CommitMessage: data.CommitMessage, 912 + // } 913 + // 914 + // patch := data.Patch 915 + // branch := data.Branch 916 + // gr, err := git.Open(path, branch) 917 + // if err != nil { 918 + // notFound(w) 919 + // return 920 + // } 921 + // 922 + // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 + // 924 + // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 + // var mergeErr *git.ErrMerge 926 + // if errors.As(err, &mergeErr) { 927 + // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 + // for i, conflict := range mergeErr.Conflicts { 929 + // conflicts[i] = types.ConflictInfo{ 930 + // Filename: conflict.Filename, 931 + // Reason: conflict.Reason, 932 + // } 933 + // } 934 + // response := types.MergeCheckResponse{ 935 + // IsConflicted: true, 936 + // Conflicts: conflicts, 937 + // Message: mergeErr.Message, 938 + // } 939 + // writeConflict(w, response) 940 + // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 + // } else { 942 + // writeError(w, err.Error(), http.StatusBadRequest) 943 + // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 + // } 945 + // return 946 + // } 947 + // 948 + // w.WriteHeader(http.StatusOK) 949 + // } 950 + 951 + // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 + // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 + // 954 + // var data struct { 955 + // Patch string `json:"patch"` 956 + // Branch string `json:"branch"` 957 + // } 958 + // 959 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 + // writeError(w, err.Error(), http.StatusBadRequest) 961 + // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 + // return 963 + // } 964 + // 965 + // patch := data.Patch 966 + // branch := data.Branch 967 + // gr, err := git.Open(path, branch) 968 + // if err != nil { 969 + // notFound(w) 970 + // return 971 + // } 972 + // 973 + // err = gr.MergeCheck([]byte(patch), branch) 974 + // if err == nil { 975 + // response := types.MergeCheckResponse{ 976 + // IsConflicted: false, 977 + // } 978 + // writeJSON(w, response) 979 + // return 980 + // } 981 + // 982 + // var mergeErr *git.ErrMerge 983 + // if errors.As(err, &mergeErr) { 984 + // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 + // for i, conflict := range mergeErr.Conflicts { 986 + // conflicts[i] = types.ConflictInfo{ 987 + // Filename: conflict.Filename, 988 + // Reason: conflict.Reason, 989 + // } 990 + // } 991 + // response := types.MergeCheckResponse{ 992 + // IsConflicted: true, 993 + // Conflicts: conflicts, 994 + // Message: mergeErr.Message, 995 + // } 996 + // writeConflict(w, response) 997 + // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 + // return 999 + // } 1000 + // writeError(w, err.Error(), http.StatusInternalServerError) 1001 + // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 + // } 1003 + 1004 + func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 + rev1 := chi.URLParam(r, "rev1") 1006 + rev1, _ = url.PathUnescape(rev1) 1007 + 1008 + rev2 := chi.URLParam(r, "rev2") 1009 + rev2, _ = url.PathUnescape(rev2) 1010 + 1011 + l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 + 1013 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 + gr, err := git.PlainOpen(path) 1015 + if err != nil { 1016 + notFound(w) 1017 + return 1018 + } 1019 + 1020 + commit1, err := gr.ResolveRevision(rev1) 1021 + if err != nil { 1022 + l.Error("error resolving revision 1", "msg", err.Error()) 1023 + writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 + return 1025 + } 1026 + 1027 + commit2, err := gr.ResolveRevision(rev2) 1028 + if err != nil { 1029 + l.Error("error resolving revision 2", "msg", err.Error()) 1030 + writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 + return 1032 + } 1033 + 1034 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 + if err != nil { 1036 + l.Error("error comparing revisions", "msg", err.Error()) 1037 + writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 + return 1039 + } 1040 + 1041 + writeJSON(w, types.RepoFormatPatchResponse{ 1042 + Rev1: commit1.Hash.String(), 1043 + Rev2: commit2.Hash.String(), 1044 + FormatPatch: formatPatch, 1045 + Patch: rawPatch, 1046 + }) 1047 + } 1048 + 1049 + func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 + l := h.l.With("handler", "DefaultBranch") 1051 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 + 1053 + gr, err := git.Open(path, "") 1054 + if err != nil { 1055 + notFound(w) 1056 + return 1057 + } 1058 + 1059 + branch, err := gr.FindMainBranch() 1060 + if err != nil { 1061 + writeError(w, err.Error(), http.StatusInternalServerError) 1062 + l.Error("getting default branch", "error", err.Error()) 1063 + return 1064 + } 1065 + 1066 + writeJSON(w, types.RepoDefaultBranchResponse{ 1067 + Branch: branch, 1068 + }) 211 1069 }
-10
knotserver/http_util.go
··· 20 20 func notFound(w http.ResponseWriter) { 21 21 writeError(w, "not found", http.StatusNotFound) 22 22 } 23 - 24 - func writeMsg(w http.ResponseWriter, msg string) { 25 - writeJSON(w, map[string]string{"msg": msg}) 26 - } 27 - 28 - func writeConflict(w http.ResponseWriter, data interface{}) { 29 - w.Header().Set("Content-Type", "application/json") 30 - w.WriteHeader(http.StatusConflict) 31 - json.NewEncoder(w).Encode(data) 32 - }
+75 -90
knotserver/ingester.go
··· 8 8 "net/http" 9 9 "net/url" 10 10 "path/filepath" 11 - "slices" 12 11 "strings" 13 12 14 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 25 24 "tangled.sh/tangled.sh/core/workflow" 26 25 ) 27 26 28 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 27 + func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 29 28 l := log.FromContext(ctx) 29 + raw := json.RawMessage(event.Commit.Record) 30 + did := event.Did 31 + 32 + var record tangled.PublicKey 33 + if err := json.Unmarshal(raw, &record); err != nil { 34 + return fmt.Errorf("failed to unmarshal record: %w", err) 35 + } 36 + 30 37 pk := db.PublicKey{ 31 38 Did: did, 32 39 PublicKey: record, ··· 39 46 return nil 40 47 } 41 48 42 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 49 + func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 43 50 l := log.FromContext(ctx) 51 + raw := json.RawMessage(event.Commit.Record) 52 + did := event.Did 53 + 54 + var record tangled.KnotMember 55 + if err := json.Unmarshal(raw, &record); err != nil { 56 + return fmt.Errorf("failed to unmarshal record: %w", err) 57 + } 44 58 45 59 if record.Domain != h.c.Server.Hostname { 46 60 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 59 73 } 60 74 l.Info("added member from firehose", "member", record.Subject) 61 75 62 - if err := h.db.AddDid(did); err != nil { 76 + if err := h.db.AddDid(record.Subject); err != nil { 63 77 l.Error("failed to add did", "error", err) 64 78 return fmt.Errorf("failed to add did: %w", err) 65 79 } 66 - h.jc.AddDid(did) 80 + h.jc.AddDid(record.Subject) 67 81 68 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 69 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 70 84 } 71 85 72 86 return nil 73 87 } 74 88 75 - func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error { 89 + func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 90 + raw := json.RawMessage(event.Commit.Record) 91 + did := event.Did 92 + 93 + var record tangled.RepoPull 94 + if err := json.Unmarshal(raw, &record); err != nil { 95 + return fmt.Errorf("failed to unmarshal record: %w", err) 96 + } 97 + 76 98 l := log.FromContext(ctx) 77 99 l = l.With("handler", "processPull") 78 100 l = l.With("did", did) 79 - l = l.With("target_repo", record.TargetRepo) 80 - l = l.With("target_branch", record.TargetBranch) 101 + l = l.With("target_repo", record.Target.Repo) 102 + l = l.With("target_branch", record.Target.Branch) 81 103 82 104 if record.Source == nil { 83 - reason := "not a branch-based pull request" 84 - l.Info("ignoring pull record", "reason", reason) 85 - return fmt.Errorf("ignoring pull record: %s", reason) 105 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 86 106 } 87 107 88 108 if record.Source.Repo != nil { 89 - reason := "fork based pull" 90 - l.Info("ignoring pull record", "reason", reason) 91 - return fmt.Errorf("ignoring pull record: %s", reason) 109 + return fmt.Errorf("ignoring pull record: fork based pull") 92 110 } 93 111 94 - allDids, err := h.db.GetAllDids() 112 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 95 113 if err != nil { 96 - return err 97 - } 98 - 99 - // presently: we only process PRs from collaborators for pipelines 100 - if !slices.Contains(allDids, did) { 101 - reason := "not a known did" 102 - l.Info("rejecting pull record", "reason", reason) 103 - return fmt.Errorf("rejected pull record: %s, %s", reason, did) 104 - } 105 - 106 - repoAt, err := syntax.ParseATURI(record.TargetRepo) 107 - if err != nil { 108 - return err 114 + return fmt.Errorf("failed to parse ATURI: %w", err) 109 115 } 110 116 111 117 // resolve this aturi to extract the repo record ··· 121 127 122 128 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 123 129 if err != nil { 124 - return err 130 + return fmt.Errorf("failed to resolver repo: %w", err) 125 131 } 126 132 127 133 repo := resp.Value.Val.(*tangled.Repo) 128 134 129 135 if repo.Knot != h.c.Server.Hostname { 130 - reason := "not this knot" 131 - l.Info("rejecting pull record", "reason", reason) 132 - return fmt.Errorf("rejected pull record: %s", reason) 136 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 133 137 } 134 138 135 139 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 136 140 if err != nil { 137 - return err 141 + return fmt.Errorf("failed to construct relative repo path: %w", err) 138 142 } 139 143 140 144 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 141 145 if err != nil { 142 - return err 146 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 143 147 } 144 148 145 149 gr, err := git.Open(repoPath, record.Source.Branch) 146 150 if err != nil { 147 - return err 151 + return fmt.Errorf("failed to open git repository: %w", err) 148 152 } 149 153 150 154 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 151 155 if err != nil { 152 - return err 156 + return fmt.Errorf("failed to open workflow directory: %w", err) 153 157 } 154 158 155 - var pipeline workflow.Pipeline 159 + var pipeline workflow.RawPipeline 156 160 for _, e := range workflowDir { 157 161 if !e.IsFile { 158 162 continue ··· 164 168 continue 165 169 } 166 170 167 - wf, err := workflow.FromFile(e.Name, contents) 168 - if err != nil { 169 - // TODO: log here, respond to client that is pushing 170 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 171 - continue 172 - } 173 - 174 - pipeline = append(pipeline, wf) 171 + pipeline = append(pipeline, workflow.RawWorkflow{ 172 + Name: e.Name, 173 + Contents: contents, 174 + }) 175 175 } 176 176 177 177 trigger := tangled.Pipeline_PullRequestTriggerData{ 178 178 Action: "create", 179 179 SourceBranch: record.Source.Branch, 180 180 SourceSha: record.Source.Sha, 181 - TargetBranch: record.TargetBranch, 181 + TargetBranch: record.Target.Branch, 182 182 } 183 183 184 184 compiler := workflow.Compiler{ ··· 193 193 }, 194 194 } 195 195 196 - cp := compiler.Compile(pipeline) 196 + cp := compiler.Compile(compiler.Parse(pipeline)) 197 197 eventJson, err := json.Marshal(cp) 198 198 if err != nil { 199 - return err 199 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 200 200 } 201 201 202 202 // do not run empty pipelines ··· 204 204 return nil 205 205 } 206 206 207 - event := db.Event{ 207 + ev := db.Event{ 208 208 Rkey: TID(), 209 209 Nsid: tangled.PipelineNSID, 210 210 EventJson: string(eventJson), 211 211 } 212 212 213 - return h.db.InsertEvent(event, h.n) 213 + return h.db.InsertEvent(ev, h.n) 214 214 } 215 215 216 216 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 217 + func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 218 + raw := json.RawMessage(event.Commit.Record) 219 + did := event.Did 220 + 221 + var record tangled.RepoCollaborator 222 + if err := json.Unmarshal(raw, &record); err != nil { 223 + return fmt.Errorf("failed to unmarshal record: %w", err) 224 + } 225 + 218 226 repoAt, err := syntax.ParseATURI(record.Repo) 219 227 if err != nil { 220 228 return err ··· 247 255 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 256 249 257 // check perms for this user 250 - if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 251 - return fmt.Errorf("insufficient permissions: %w", err) 258 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 259 + if err != nil { 260 + return fmt.Errorf("failed to check permissions: %w", err) 261 + } 262 + if !ok { 263 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 252 264 } 253 265 254 266 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 290 302 return fmt.Errorf("error reading response body: %w", err) 291 303 } 292 304 293 - for _, key := range strings.Split(string(plaintext), "\n") { 305 + for key := range strings.SplitSeq(string(plaintext), "\n") { 294 306 if key == "" { 295 307 continue 296 308 } ··· 307 319 } 308 320 309 321 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 310 - did := event.Did 311 322 if event.Kind != models.EventKindCommit { 312 323 return nil 313 324 } ··· 321 332 } 322 333 }() 323 334 324 - raw := json.RawMessage(event.Commit.Record) 325 - 326 335 switch event.Commit.Collection { 327 336 case tangled.PublicKeyNSID: 328 - var record tangled.PublicKey 329 - if err := json.Unmarshal(raw, &record); err != nil { 330 - return fmt.Errorf("failed to unmarshal record: %w", err) 331 - } 332 - if err := h.processPublicKey(ctx, did, record); err != nil { 333 - return fmt.Errorf("failed to process public key: %w", err) 334 - } 335 - 337 + err = h.processPublicKey(ctx, event) 336 338 case tangled.KnotMemberNSID: 337 - var record tangled.KnotMember 338 - if err := json.Unmarshal(raw, &record); err != nil { 339 - return fmt.Errorf("failed to unmarshal record: %w", err) 340 - } 341 - if err := h.processKnotMember(ctx, did, record); err != nil { 342 - return fmt.Errorf("failed to process knot member: %w", err) 343 - } 344 - 339 + err = h.processKnotMember(ctx, event) 345 340 case tangled.RepoPullNSID: 346 - var record tangled.RepoPull 347 - if err := json.Unmarshal(raw, &record); err != nil { 348 - return fmt.Errorf("failed to unmarshal record: %w", err) 349 - } 350 - if err := h.processPull(ctx, did, record); err != nil { 351 - return fmt.Errorf("failed to process knot member: %w", err) 352 - } 353 - 341 + err = h.processPull(ctx, event) 354 342 case tangled.RepoCollaboratorNSID: 355 - var record tangled.RepoCollaborator 356 - if err := json.Unmarshal(raw, &record); err != nil { 357 - return fmt.Errorf("failed to unmarshal record: %w", err) 358 - } 359 - if err := h.processCollaborator(ctx, did, record); err != nil { 360 - return fmt.Errorf("failed to process knot member: %w", err) 361 - } 343 + err = h.processCollaborator(ctx, event) 344 + } 362 345 346 + if err != nil { 347 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 363 348 } 364 349 365 - return err 350 + return nil 366 351 }
+20 -39
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 46 47 } 47 48 48 49 w.WriteHeader(http.StatusNoContent) 49 - return 50 50 } 51 51 52 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 62 62 data = append(data, j) 63 63 } 64 64 writeJSON(w, data) 65 - return 66 65 } 67 66 68 67 type PushOptions struct { ··· 145 144 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 145 } 147 146 148 - meta := gr.RefUpdateMeta(line) 147 + var errs error 148 + meta, err := gr.RefUpdateMeta(line) 149 + errors.Join(errs, err) 149 150 150 151 metaRecord := meta.AsRecord() 151 152 ··· 169 170 EventJson: string(eventJson), 170 171 } 171 172 172 - return h.db.InsertEvent(event, h.n) 173 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 173 174 } 174 175 175 176 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { ··· 197 198 return err 198 199 } 199 200 200 - pipelineParseErrors := []string{} 201 - 202 - var pipeline workflow.Pipeline 201 + var pipeline workflow.RawPipeline 203 202 for _, e := range workflowDir { 204 203 if !e.IsFile { 205 204 continue ··· 211 210 continue 212 211 } 213 212 214 - wf, err := workflow.FromFile(e.Name, contents) 215 - if err != nil { 216 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 217 - pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 218 - continue 219 - } 220 - 221 - pipeline = append(pipeline, wf) 213 + pipeline = append(pipeline, workflow.RawWorkflow{ 214 + Name: e.Name, 215 + Contents: contents, 216 + }) 222 217 } 223 218 224 219 trigger := tangled.Pipeline_PushTriggerData{ ··· 239 234 }, 240 235 } 241 236 242 - cp := compiler.Compile(pipeline) 237 + cp := compiler.Compile(compiler.Parse(pipeline)) 243 238 eventJson, err := json.Marshal(cp) 244 239 if err != nil { 245 240 return err 246 241 } 247 242 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 248 247 if pushOptions.verboseCi { 249 - hasDiagnostics := false 250 - if len(pipelineParseErrors) > 0 { 251 - hasDiagnostics = true 252 - *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 253 - for _, error := range pipelineParseErrors { 254 - *clientMsgs = append(*clientMsgs, error) 255 - } 248 + if compiler.Diagnostics.IsEmpty() { 249 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 256 250 } 257 - if len(compiler.Diagnostics.Errors) > 0 { 258 - hasDiagnostics = true 259 - *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 260 - for _, error := range compiler.Diagnostics.Errors { 261 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 262 - } 263 - } 264 - if len(compiler.Diagnostics.Warnings) > 0 { 265 - hasDiagnostics = true 266 - *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 267 - for _, warning := range compiler.Diagnostics.Warnings { 268 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 269 - } 270 - } 271 - if !hasDiagnostics { 272 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 251 + 252 + for _, w := range compiler.Diagnostics.Warnings { 253 + *clientMsgs = append(*clientMsgs, w.String()) 273 254 } 274 255 } 275 256
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 - "net/http" 8 - "time" 9 - ) 10 - 11 - func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 - if h.c.Server.Dev { 13 - return next 14 - } 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - signature := r.Header.Get("X-Signature") 17 - if signature == "" || !h.verifyHMAC(signature, r) { 18 - writeError(w, "signature verification failed", http.StatusForbidden) 19 - return 20 - } 21 - next.ServeHTTP(w, r) 22 - }) 23 - } 24 - 25 - func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 - secret := h.c.Server.Secret 27 - timestamp := r.Header.Get("X-Timestamp") 28 - if timestamp == "" { 29 - return false 30 - } 31 - 32 - // Verify that the timestamp is not older than a minute 33 - reqTime, err := time.Parse(time.RFC3339, timestamp) 34 - if err != nil { 35 - return false 36 - } 37 - if time.Since(reqTime) > time.Minute { 38 - return false 39 - } 40 - 41 - message := r.Method + r.URL.Path + timestamp 42 - 43 - mac := hmac.New(sha256.New, []byte(secret)) 44 - mac.Write([]byte(message)) 45 - expectedMAC := mac.Sum(nil) 46 - 47 - signatureBytes, err := hex.DecodeString(signature) 48 - if err != nil { 49 - return false 50 - } 51 - 52 - return hmac.Equal(signatureBytes, expectedMAC) 53 - }
+142 -1273
knotserver/routes.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "compress/gzip" 5 4 "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 5 "fmt" 12 - "log" 6 + "log/slog" 13 7 "net/http" 14 - "net/url" 15 - "os" 16 - "path/filepath" 17 - "strconv" 18 - "strings" 19 - "sync" 20 - "time" 8 + "runtime/debug" 21 9 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 10 "github.com/go-chi/chi/v5" 25 - gogit "github.com/go-git/go-git/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 - "github.com/go-git/go-git/v5/plumbing/object" 28 - "tangled.sh/tangled.sh/core/hook" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 29 14 "tangled.sh/tangled.sh/core/knotserver/db" 30 - "tangled.sh/tangled.sh/core/knotserver/git" 31 - "tangled.sh/tangled.sh/core/patchutil" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 + "tangled.sh/tangled.sh/core/notifier" 32 18 "tangled.sh/tangled.sh/core/rbac" 33 - "tangled.sh/tangled.sh/core/types" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 34 20 ) 35 21 36 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 37 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 38 - } 39 - 40 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 - w.Header().Set("Content-Type", "application/json") 42 - 43 - capabilities := map[string]any{ 44 - "pull_requests": map[string]any{ 45 - "format_patch": true, 46 - "patch_submissions": true, 47 - "branch_submissions": true, 48 - "fork_submissions": true, 49 - }, 50 - } 51 - 52 - jsonData, err := json.Marshal(capabilities) 53 - if err != nil { 54 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 - return 56 - } 57 - 58 - w.Write(jsonData) 59 - } 60 - 61 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 62 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 63 - l := h.l.With("path", path, "handler", "RepoIndex") 64 - ref := chi.URLParam(r, "ref") 65 - ref, _ = url.PathUnescape(ref) 66 - 67 - gr, err := git.Open(path, ref) 68 - if err != nil { 69 - plain, err2 := git.PlainOpen(path) 70 - if err2 != nil { 71 - l.Error("opening repo", "error", err2.Error()) 72 - notFound(w) 73 - return 74 - } 75 - branches, _ := plain.Branches() 76 - 77 - log.Println(err) 78 - 79 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 80 - resp := types.RepoIndexResponse{ 81 - IsEmpty: true, 82 - Branches: branches, 83 - } 84 - writeJSON(w, resp) 85 - return 86 - } else { 87 - l.Error("opening repo", "error", err.Error()) 88 - notFound(w) 89 - return 90 - } 91 - } 92 - 93 - var ( 94 - commits []*object.Commit 95 - total int 96 - branches []types.Branch 97 - files []types.NiceTree 98 - tags []object.Tag 99 - ) 100 - 101 - var wg sync.WaitGroup 102 - errorsCh := make(chan error, 5) 103 - 104 - wg.Add(1) 105 - go func() { 106 - defer wg.Done() 107 - cs, err := gr.Commits(0, 60) 108 - if err != nil { 109 - errorsCh <- fmt.Errorf("commits: %w", err) 110 - return 111 - } 112 - commits = cs 113 - }() 114 - 115 - wg.Add(1) 116 - go func() { 117 - defer wg.Done() 118 - t, err := gr.TotalCommits() 119 - if err != nil { 120 - errorsCh <- fmt.Errorf("calculating total: %w", err) 121 - return 122 - } 123 - total = t 124 - }() 125 - 126 - wg.Add(1) 127 - go func() { 128 - defer wg.Done() 129 - bs, err := gr.Branches() 130 - if err != nil { 131 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 - return 133 - } 134 - branches = bs 135 - }() 136 - 137 - wg.Add(1) 138 - go func() { 139 - defer wg.Done() 140 - ts, err := gr.Tags() 141 - if err != nil { 142 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 - return 144 - } 145 - tags = ts 146 - }() 147 - 148 - wg.Add(1) 149 - go func() { 150 - defer wg.Done() 151 - fs, err := gr.FileTree(r.Context(), "") 152 - if err != nil { 153 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 - return 155 - } 156 - files = fs 157 - }() 158 - 159 - wg.Wait() 160 - close(errorsCh) 161 - 162 - // show any errors 163 - for err := range errorsCh { 164 - l.Error("loading repo", "error", err.Error()) 165 - writeError(w, err.Error(), http.StatusInternalServerError) 166 - return 167 - } 168 - 169 - rtags := []*types.TagReference{} 170 - for _, tag := range tags { 171 - var target *object.Tag 172 - if tag.Target != plumbing.ZeroHash { 173 - target = &tag 174 - } 175 - tr := types.TagReference{ 176 - Tag: target, 177 - } 178 - 179 - tr.Reference = types.Reference{ 180 - Name: tag.Name, 181 - Hash: tag.Hash.String(), 182 - } 183 - 184 - if tag.Message != "" { 185 - tr.Message = tag.Message 186 - } 187 - 188 - rtags = append(rtags, &tr) 189 - } 190 - 191 - var readmeContent string 192 - var readmeFile string 193 - for _, readme := range h.c.Repo.Readme { 194 - content, _ := gr.FileContent(readme) 195 - if len(content) > 0 { 196 - readmeContent = string(content) 197 - readmeFile = readme 198 - } 199 - } 200 - 201 - if ref == "" { 202 - mainBranch, err := gr.FindMainBranch() 203 - if err != nil { 204 - writeError(w, err.Error(), http.StatusInternalServerError) 205 - l.Error("finding main branch", "error", err.Error()) 206 - return 207 - } 208 - ref = mainBranch 209 - } 210 - 211 - resp := types.RepoIndexResponse{ 212 - IsEmpty: false, 213 - Ref: ref, 214 - Commits: commits, 215 - Description: getDescription(path), 216 - Readme: readmeContent, 217 - ReadmeFileName: readmeFile, 218 - Files: files, 219 - Branches: branches, 220 - Tags: rtags, 221 - TotalCommits: total, 222 - } 223 - 224 - writeJSON(w, resp) 225 - return 226 - } 227 - 228 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 229 - treePath := chi.URLParam(r, "*") 230 - ref := chi.URLParam(r, "ref") 231 - ref, _ = url.PathUnescape(ref) 232 - 233 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 234 - 235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 236 - gr, err := git.Open(path, ref) 237 - if err != nil { 238 - notFound(w) 239 - return 240 - } 241 - 242 - files, err := gr.FileTree(r.Context(), treePath) 243 - if err != nil { 244 - writeError(w, err.Error(), http.StatusInternalServerError) 245 - l.Error("file tree", "error", err.Error()) 246 - return 247 - } 248 - 249 - resp := types.RepoTreeResponse{ 250 - Ref: ref, 251 - Parent: treePath, 252 - Description: getDescription(path), 253 - DotDot: filepath.Dir(treePath), 254 - Files: files, 255 - } 256 - 257 - writeJSON(w, resp) 258 - return 259 - } 260 - 261 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 262 - treePath := chi.URLParam(r, "*") 263 - ref := chi.URLParam(r, "ref") 264 - ref, _ = url.PathUnescape(ref) 265 - 266 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 267 - 268 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 269 - gr, err := git.Open(path, ref) 270 - if err != nil { 271 - notFound(w) 272 - return 273 - } 274 - 275 - contents, err := gr.RawContent(treePath) 276 - if err != nil { 277 - writeError(w, err.Error(), http.StatusBadRequest) 278 - l.Error("file content", "error", err.Error()) 279 - return 280 - } 281 - 282 - mimeType := http.DetectContentType(contents) 283 - 284 - // exception for svg 285 - if filepath.Ext(treePath) == ".svg" { 286 - mimeType = "image/svg+xml" 287 - } 288 - 289 - // allow image, video, and text/plain files to be served directly 290 - switch { 291 - case strings.HasPrefix(mimeType, "image/"): 292 - // allowed 293 - case strings.HasPrefix(mimeType, "video/"): 294 - // allowed 295 - case strings.HasPrefix(mimeType, "text/plain"): 296 - // allowed 297 - default: 298 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 300 - return 301 - } 302 - 303 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 304 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 305 - w.Header().Set("Content-Type", mimeType) 306 - w.Write(contents) 307 - } 308 - 309 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 310 - treePath := chi.URLParam(r, "*") 311 - ref := chi.URLParam(r, "ref") 312 - ref, _ = url.PathUnescape(ref) 313 - 314 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 315 - 316 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 317 - gr, err := git.Open(path, ref) 318 - if err != nil { 319 - notFound(w) 320 - return 321 - } 322 - 323 - var isBinaryFile bool = false 324 - contents, err := gr.FileContent(treePath) 325 - if errors.Is(err, git.ErrBinaryFile) { 326 - isBinaryFile = true 327 - } else if errors.Is(err, object.ErrFileNotFound) { 328 - notFound(w) 329 - return 330 - } else if err != nil { 331 - writeError(w, err.Error(), http.StatusInternalServerError) 332 - return 333 - } 334 - 335 - bytes := []byte(contents) 336 - // safe := string(sanitize(bytes)) 337 - sizeHint := len(bytes) 338 - 339 - resp := types.RepoBlobResponse{ 340 - Ref: ref, 341 - Contents: string(bytes), 342 - Path: treePath, 343 - IsBinary: isBinaryFile, 344 - SizeHint: uint64(sizeHint), 345 - } 346 - 347 - h.showFile(resp, w, l) 348 - } 349 - 350 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 351 - name := chi.URLParam(r, "name") 352 - file := chi.URLParam(r, "file") 353 - 354 - l := h.l.With("handler", "Archive", "name", name, "file", file) 355 - 356 - // TODO: extend this to add more files compression (e.g.: xz) 357 - if !strings.HasSuffix(file, ".tar.gz") { 358 - notFound(w) 359 - return 360 - } 361 - 362 - ref := strings.TrimSuffix(file, ".tar.gz") 363 - 364 - // This allows the browser to use a proper name for the file when 365 - // downloading 366 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 367 - setContentDisposition(w, filename) 368 - setGZipMIME(w) 369 - 370 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 371 - gr, err := git.Open(path, ref) 372 - if err != nil { 373 - notFound(w) 374 - return 375 - } 376 - 377 - gw := gzip.NewWriter(w) 378 - defer gw.Close() 379 - 380 - prefix := fmt.Sprintf("%s-%s", name, ref) 381 - err = gr.WriteTar(gw, prefix) 382 - if err != nil { 383 - // once we start writing to the body we can't report error anymore 384 - // so we are only left with printing the error. 385 - l.Error("writing tar file", "error", err.Error()) 386 - return 387 - } 388 - 389 - err = gw.Flush() 390 - if err != nil { 391 - // once we start writing to the body we can't report error anymore 392 - // so we are only left with printing the error. 393 - l.Error("flushing?", "error", err.Error()) 394 - return 395 - } 22 + type Handle struct { 23 + c *config.Config 24 + db *db.DB 25 + jc *jetstream.JetstreamClient 26 + e *rbac.Enforcer 27 + l *slog.Logger 28 + n *notifier.Notifier 29 + resolver *idresolver.Resolver 396 30 } 397 31 398 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 399 - ref := chi.URLParam(r, "ref") 400 - ref, _ = url.PathUnescape(ref) 401 - 402 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 403 - 404 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 405 - 406 - gr, err := git.Open(path, ref) 407 - if err != nil { 408 - notFound(w) 409 - return 410 - } 411 - 412 - // Get page parameters 413 - page := 1 414 - pageSize := 30 415 - 416 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 417 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 418 - page = p 419 - } 420 - } 32 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 + r := chi.NewRouter() 421 34 422 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 423 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 424 - pageSize = ps 425 - } 35 + h := Handle{ 36 + c: c, 37 + db: db, 38 + e: e, 39 + l: l, 40 + jc: jc, 41 + n: n, 42 + resolver: idresolver.DefaultResolver(), 426 43 } 427 44 428 - // convert to offset/limit 429 - offset := (page - 1) * pageSize 430 - limit := pageSize 431 - 432 - commits, err := gr.Commits(offset, limit) 45 + err := e.AddKnot(rbac.ThisServer) 433 46 if err != nil { 434 - writeError(w, err.Error(), http.StatusInternalServerError) 435 - l.Error("fetching commits", "error", err.Error()) 436 - return 437 - } 438 - 439 - total := len(commits) 440 - 441 - resp := types.RepoLogResponse{ 442 - Commits: commits, 443 - Ref: ref, 444 - Description: getDescription(path), 445 - Log: true, 446 - Total: total, 447 - Page: page, 448 - PerPage: pageSize, 47 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 449 48 } 450 49 451 - writeJSON(w, resp) 452 - return 453 - } 454 - 455 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 456 - ref := chi.URLParam(r, "ref") 457 - ref, _ = url.PathUnescape(ref) 458 - 459 - l := h.l.With("handler", "Diff", "ref", ref) 460 - 461 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 462 - gr, err := git.Open(path, ref) 463 - if err != nil { 464 - notFound(w) 465 - return 50 + // configure owner 51 + if err = h.configureOwner(); err != nil { 52 + return nil, err 466 53 } 54 + h.l.Info("owner set", "did", h.c.Server.Owner) 55 + h.jc.AddDid(h.c.Server.Owner) 467 56 468 - diff, err := gr.Diff() 57 + // configure known-dids in jetstream consumer 58 + dids, err := h.db.GetAllDids() 469 59 if err != nil { 470 - writeError(w, err.Error(), http.StatusInternalServerError) 471 - l.Error("getting diff", "error", err.Error()) 472 - return 473 - } 474 - 475 - resp := types.RepoCommitResponse{ 476 - Ref: ref, 477 - Diff: diff, 60 + return nil, fmt.Errorf("failed to get all dids: %w", err) 478 61 } 479 - 480 - writeJSON(w, resp) 481 - return 482 - } 483 - 484 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 485 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 486 - l := h.l.With("handler", "Refs") 487 - 488 - gr, err := git.Open(path, "") 489 - if err != nil { 490 - notFound(w) 491 - return 62 + for _, d := range dids { 63 + jc.AddDid(d) 492 64 } 493 65 494 - tags, err := gr.Tags() 66 + err = h.jc.StartJetstream(ctx, h.processMessages) 495 67 if err != nil { 496 - // Non-fatal, we *should* have at least one branch to show. 497 - l.Warn("getting tags", "error", err.Error()) 68 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 498 69 } 499 70 500 - rtags := []*types.TagReference{} 501 - for _, tag := range tags { 502 - var target *object.Tag 503 - if tag.Target != plumbing.ZeroHash { 504 - target = &tag 505 - } 506 - tr := types.TagReference{ 507 - Tag: target, 508 - } 71 + r.Get("/", h.Index) 72 + r.Get("/capabilities", h.Capabilities) 73 + r.Get("/version", h.Version) 74 + r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 + w.Write([]byte(h.c.Server.Owner)) 76 + }) 77 + r.Route("/{did}", func(r chi.Router) { 78 + // Repo routes 79 + r.Route("/{name}", func(r chi.Router) { 509 80 510 - tr.Reference = types.Reference{ 511 - Name: tag.Name, 512 - Hash: tag.Hash.String(), 513 - } 81 + r.Route("/languages", func(r chi.Router) { 82 + r.Get("/", h.RepoLanguages) 83 + r.Get("/{ref}", h.RepoLanguages) 84 + }) 514 85 515 - if tag.Message != "" { 516 - tr.Message = tag.Message 517 - } 86 + r.Get("/", h.RepoIndex) 87 + r.Get("/info/refs", h.InfoRefs) 88 + r.Post("/git-upload-pack", h.UploadPack) 89 + r.Post("/git-receive-pack", h.ReceivePack) 90 + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 518 91 519 - rtags = append(rtags, &tr) 520 - } 92 + r.Route("/tree/{ref}", func(r chi.Router) { 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/*", h.RepoTree) 95 + }) 521 96 522 - resp := types.RepoTagsResponse{ 523 - Tags: rtags, 524 - } 97 + r.Route("/blob/{ref}", func(r chi.Router) { 98 + r.Get("/*", h.Blob) 99 + }) 525 100 526 - writeJSON(w, resp) 527 - return 528 - } 101 + r.Route("/raw/{ref}", func(r chi.Router) { 102 + r.Get("/*", h.BlobRaw) 103 + }) 529 104 530 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 531 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 105 + r.Get("/log/{ref}", h.Log) 106 + r.Get("/archive/{file}", h.Archive) 107 + r.Get("/commit/{ref}", h.Diff) 108 + r.Get("/tags", h.Tags) 109 + r.Route("/branches", func(r chi.Router) { 110 + r.Get("/", h.Branches) 111 + r.Get("/{branch}", h.Branch) 112 + r.Get("/default", h.DefaultBranch) 113 + }) 114 + }) 115 + }) 532 116 533 - gr, err := git.PlainOpen(path) 534 - if err != nil { 535 - notFound(w) 536 - return 537 - } 117 + // xrpc apis 118 + r.Mount("/xrpc", h.XrpcRouter()) 538 119 539 - branches, _ := gr.Branches() 120 + // Socket that streams git oplogs 121 + r.Get("/events", h.Events) 540 122 541 - resp := types.RepoBranchesResponse{ 542 - Branches: branches, 543 - } 123 + // All public keys on the knot. 124 + r.Get("/keys", h.Keys) 544 125 545 - writeJSON(w, resp) 546 - return 126 + return r, nil 547 127 } 548 128 549 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 550 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 551 - branchName := chi.URLParam(r, "branch") 552 - branchName, _ = url.PathUnescape(branchName) 129 + func (h *Handle) XrpcRouter() http.Handler { 130 + logger := tlog.New("knots") 553 131 554 - l := h.l.With("handler", "Branch") 132 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 555 133 556 - gr, err := git.PlainOpen(path) 557 - if err != nil { 558 - notFound(w) 559 - return 134 + xrpc := &xrpc.Xrpc{ 135 + Config: h.c, 136 + Db: h.db, 137 + Ingester: h.jc, 138 + Enforcer: h.e, 139 + Logger: logger, 140 + Notifier: h.n, 141 + Resolver: h.resolver, 142 + ServiceAuth: serviceAuth, 560 143 } 561 - 562 - ref, err := gr.Branch(branchName) 563 - if err != nil { 564 - l.Error("getting branch", "error", err.Error()) 565 - writeError(w, err.Error(), http.StatusInternalServerError) 566 - return 567 - } 568 - 569 - commit, err := gr.Commit(ref.Hash()) 570 - if err != nil { 571 - l.Error("getting commit object", "error", err.Error()) 572 - writeError(w, err.Error(), http.StatusInternalServerError) 573 - return 574 - } 575 - 576 - defaultBranch, err := gr.FindMainBranch() 577 - isDefault := false 578 - if err != nil { 579 - l.Error("getting default branch", "error", err.Error()) 580 - // do not quit though 581 - } else if defaultBranch == branchName { 582 - isDefault = true 583 - } 584 - 585 - resp := types.RepoBranchResponse{ 586 - Branch: types.Branch{ 587 - Reference: types.Reference{ 588 - Name: ref.Name().Short(), 589 - Hash: ref.Hash().String(), 590 - }, 591 - Commit: commit, 592 - IsDefault: isDefault, 593 - }, 594 - } 595 - 596 - writeJSON(w, resp) 597 - return 144 + return xrpc.Router() 598 145 } 599 146 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 147 + // version is set during build time. 148 + var version string 611 149 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 619 - 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 150 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 + if version == "" { 152 + info, ok := debug.ReadBuildInfo() 153 + if !ok { 154 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 624 155 return 625 156 } 626 157 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 158 + var modVer string 159 + for _, mod := range info.Deps { 160 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 + version = mod.Version 162 + break 163 + } 630 164 } 631 165 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 166 + if modVer == "" { 167 + version = "unknown" 636 168 } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 169 } 641 - } 642 170 643 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 644 - l := h.l.With("handler", "NewRepo") 645 - 646 - data := struct { 647 - Did string `json:"did"` 648 - Name string `json:"name"` 649 - DefaultBranch string `json:"default_branch,omitempty"` 650 - }{} 651 - 652 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 653 - writeError(w, "invalid request body", http.StatusBadRequest) 654 - return 655 - } 656 - 657 - if data.DefaultBranch == "" { 658 - data.DefaultBranch = h.c.Repo.MainBranch 659 - } 660 - 661 - did := data.Did 662 - name := data.Name 663 - defaultBranch := data.DefaultBranch 664 - 665 - if err := validateRepoName(name); err != nil { 666 - l.Error("creating repo", "error", err.Error()) 667 - writeError(w, err.Error(), http.StatusBadRequest) 668 - return 669 - } 670 - 671 - relativeRepoPath := filepath.Join(did, name) 672 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 673 - err := git.InitBare(repoPath, defaultBranch) 674 - if err != nil { 675 - l.Error("initializing bare repo", "error", err.Error()) 676 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 677 - writeError(w, "That repo already exists!", http.StatusConflict) 678 - return 679 - } else { 680 - writeError(w, err.Error(), http.StatusInternalServerError) 681 - return 682 - } 683 - } 684 - 685 - // add perms for this user to access the repo 686 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 687 - if err != nil { 688 - l.Error("adding repo permissions", "error", err.Error()) 689 - writeError(w, err.Error(), http.StatusInternalServerError) 690 - return 691 - } 692 - 693 - hook.SetupRepo( 694 - hook.Config( 695 - hook.WithScanPath(h.c.Repo.ScanPath), 696 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 697 - ), 698 - repoPath, 699 - ) 700 - 701 - w.WriteHeader(http.StatusNoContent) 171 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 + fmt.Fprintf(w, "knotserver/%s", version) 702 173 } 703 174 704 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 705 - l := h.l.With("handler", "RepoForkSync") 706 - 707 - data := struct { 708 - Did string `json:"did"` 709 - Source string `json:"source"` 710 - Name string `json:"name,omitempty"` 711 - HiddenRef string `json:"hiddenref"` 712 - }{} 713 - 714 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 715 - writeError(w, "invalid request body", http.StatusBadRequest) 716 - return 717 - } 718 - 719 - did := data.Did 720 - source := data.Source 721 - 722 - if did == "" || source == "" { 723 - l.Error("invalid request body, empty did or name") 724 - w.WriteHeader(http.StatusBadRequest) 725 - return 726 - } 175 + func (h *Handle) configureOwner() error { 176 + cfgOwner := h.c.Server.Owner 727 177 728 - var name string 729 - if data.Name != "" { 730 - name = data.Name 731 - } else { 732 - name = filepath.Base(source) 733 - } 178 + rbacDomain := "thisserver" 734 179 735 - branch := chi.URLParam(r, "branch") 736 - branch, _ = url.PathUnescape(branch) 737 - 738 - relativeRepoPath := filepath.Join(did, name) 739 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 740 - 741 - gr, err := git.PlainOpen(repoPath) 180 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 742 181 if err != nil { 743 - log.Println(err) 744 - notFound(w) 745 - return 746 - } 747 - 748 - forkCommit, err := gr.ResolveRevision(branch) 749 - if err != nil { 750 - l.Error("error resolving ref revision", "msg", err.Error()) 751 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 752 - return 182 + return err 753 183 } 754 184 755 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 756 - if err != nil { 757 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 758 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 759 - return 760 - } 185 + switch len(existing) { 186 + case 0: 187 + // no owner configured, continue 188 + case 1: 189 + // find existing owner 190 + existingOwner := existing[0] 761 191 762 - status := types.UpToDate 763 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 764 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 765 - if err != nil { 766 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 767 - return 192 + // no ownership change, this is okay 193 + if existingOwner == h.c.Server.Owner { 194 + break 768 195 } 769 196 770 - if isAncestor { 771 - status = types.FastForwardable 772 - } else { 773 - status = types.Conflict 197 + // remove existing owner 198 + if err = h.db.RemoveDid(existingOwner); err != nil { 199 + return err 774 200 } 775 - } 776 - 777 - w.Header().Set("Content-Type", "application/json") 778 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 779 - } 780 - 781 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 782 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 783 - ref := chi.URLParam(r, "ref") 784 - ref, _ = url.PathUnescape(ref) 785 - 786 - l := h.l.With("handler", "RepoLanguages") 787 - 788 - gr, err := git.Open(repoPath, ref) 789 - if err != nil { 790 - l.Error("opening repo", "error", err.Error()) 791 - notFound(w) 792 - return 793 - } 794 - 795 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 796 - defer cancel() 797 - 798 - sizes, err := gr.AnalyzeLanguages(ctx) 799 - if err != nil { 800 - l.Error("failed to analyze languages", "error", err.Error()) 801 - writeError(w, err.Error(), http.StatusNoContent) 802 - return 803 - } 804 - 805 - resp := types.RepoLanguageResponse{Languages: sizes} 806 - 807 - writeJSON(w, resp) 808 - } 809 - 810 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 811 - l := h.l.With("handler", "RepoForkSync") 812 - 813 - data := struct { 814 - Did string `json:"did"` 815 - Source string `json:"source"` 816 - Name string `json:"name,omitempty"` 817 - }{} 818 - 819 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 820 - writeError(w, "invalid request body", http.StatusBadRequest) 821 - return 822 - } 823 - 824 - did := data.Did 825 - source := data.Source 826 - 827 - if did == "" || source == "" { 828 - l.Error("invalid request body, empty did or name") 829 - w.WriteHeader(http.StatusBadRequest) 830 - return 831 - } 832 - 833 - var name string 834 - if data.Name != "" { 835 - name = data.Name 836 - } else { 837 - name = filepath.Base(source) 838 - } 839 - 840 - branch := chi.URLParam(r, "branch") 841 - branch, _ = url.PathUnescape(branch) 842 - 843 - relativeRepoPath := filepath.Join(did, name) 844 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 845 - 846 - gr, err := git.PlainOpen(repoPath) 847 - if err != nil { 848 - log.Println(err) 849 - notFound(w) 850 - return 851 - } 852 - 853 - err = gr.Sync(branch) 854 - if err != nil { 855 - l.Error("error syncing repo fork", "error", err.Error()) 856 - writeError(w, err.Error(), http.StatusInternalServerError) 857 - return 858 - } 859 - 860 - w.WriteHeader(http.StatusNoContent) 861 - } 862 - 863 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 864 - l := h.l.With("handler", "RepoFork") 865 - 866 - data := struct { 867 - Did string `json:"did"` 868 - Source string `json:"source"` 869 - Name string `json:"name,omitempty"` 870 - }{} 871 - 872 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 873 - writeError(w, "invalid request body", http.StatusBadRequest) 874 - return 875 - } 876 - 877 - did := data.Did 878 - source := data.Source 879 - 880 - if did == "" || source == "" { 881 - l.Error("invalid request body, empty did or name") 882 - w.WriteHeader(http.StatusBadRequest) 883 - return 884 - } 885 - 886 - var name string 887 - if data.Name != "" { 888 - name = data.Name 889 - } else { 890 - name = filepath.Base(source) 891 - } 892 - 893 - relativeRepoPath := filepath.Join(did, name) 894 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 895 - 896 - err := git.Fork(repoPath, source) 897 - if err != nil { 898 - l.Error("forking repo", "error", err.Error()) 899 - writeError(w, err.Error(), http.StatusInternalServerError) 900 - return 901 - } 902 - 903 - // add perms for this user to access the repo 904 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 905 - if err != nil { 906 - l.Error("adding repo permissions", "error", err.Error()) 907 - writeError(w, err.Error(), http.StatusInternalServerError) 908 - return 909 - } 910 - 911 - hook.SetupRepo( 912 - hook.Config( 913 - hook.WithScanPath(h.c.Repo.ScanPath), 914 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 915 - ), 916 - repoPath, 917 - ) 918 - 919 - w.WriteHeader(http.StatusNoContent) 920 - } 921 - 922 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 923 - l := h.l.With("handler", "RemoveRepo") 924 - 925 - data := struct { 926 - Did string `json:"did"` 927 - Name string `json:"name"` 928 - }{} 929 - 930 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 931 - writeError(w, "invalid request body", http.StatusBadRequest) 932 - return 933 - } 934 - 935 - did := data.Did 936 - name := data.Name 937 - 938 - if did == "" || name == "" { 939 - l.Error("invalid request body, empty did or name") 940 - w.WriteHeader(http.StatusBadRequest) 941 - return 942 - } 943 - 944 - relativeRepoPath := filepath.Join(did, name) 945 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 946 - err := os.RemoveAll(repoPath) 947 - if err != nil { 948 - l.Error("removing repo", "error", err.Error()) 949 - writeError(w, err.Error(), http.StatusInternalServerError) 950 - return 951 - } 952 - 953 - w.WriteHeader(http.StatusNoContent) 954 - 955 - } 956 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 957 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 958 - 959 - data := types.MergeRequest{} 960 - 961 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 962 - writeError(w, err.Error(), http.StatusBadRequest) 963 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 964 - return 965 - } 966 - 967 - mo := &git.MergeOptions{ 968 - AuthorName: data.AuthorName, 969 - AuthorEmail: data.AuthorEmail, 970 - CommitBody: data.CommitBody, 971 - CommitMessage: data.CommitMessage, 972 - } 973 - 974 - patch := data.Patch 975 - branch := data.Branch 976 - gr, err := git.Open(path, branch) 977 - if err != nil { 978 - notFound(w) 979 - return 980 - } 981 - 982 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 983 - 984 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 985 - var mergeErr *git.ErrMerge 986 - if errors.As(err, &mergeErr) { 987 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 988 - for i, conflict := range mergeErr.Conflicts { 989 - conflicts[i] = types.ConflictInfo{ 990 - Filename: conflict.Filename, 991 - Reason: conflict.Reason, 992 - } 993 - } 994 - response := types.MergeCheckResponse{ 995 - IsConflicted: true, 996 - Conflicts: conflicts, 997 - Message: mergeErr.Message, 998 - } 999 - writeConflict(w, response) 1000 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1001 - } else { 1002 - writeError(w, err.Error(), http.StatusBadRequest) 1003 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 201 + if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 202 + return err 1004 203 } 1005 - return 1006 - } 1007 204 1008 - w.WriteHeader(http.StatusOK) 1009 - } 1010 - 1011 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1012 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1013 - 1014 - var data struct { 1015 - Patch string `json:"patch"` 1016 - Branch string `json:"branch"` 205 + default: 206 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 1017 207 } 1018 208 1019 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1020 - writeError(w, err.Error(), http.StatusBadRequest) 1021 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1022 - return 209 + if err = h.db.AddDid(cfgOwner); err != nil { 210 + return fmt.Errorf("failed to add owner to DB: %w", err) 1023 211 } 1024 - 1025 - patch := data.Patch 1026 - branch := data.Branch 1027 - gr, err := git.Open(path, branch) 1028 - if err != nil { 1029 - notFound(w) 1030 - return 212 + if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 213 + return fmt.Errorf("failed to add owner to RBAC: %w", err) 1031 214 } 1032 215 1033 - err = gr.MergeCheck([]byte(patch), branch) 1034 - if err == nil { 1035 - response := types.MergeCheckResponse{ 1036 - IsConflicted: false, 1037 - } 1038 - writeJSON(w, response) 1039 - return 1040 - } 1041 - 1042 - var mergeErr *git.ErrMerge 1043 - if errors.As(err, &mergeErr) { 1044 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1045 - for i, conflict := range mergeErr.Conflicts { 1046 - conflicts[i] = types.ConflictInfo{ 1047 - Filename: conflict.Filename, 1048 - Reason: conflict.Reason, 1049 - } 1050 - } 1051 - response := types.MergeCheckResponse{ 1052 - IsConflicted: true, 1053 - Conflicts: conflicts, 1054 - Message: mergeErr.Message, 1055 - } 1056 - writeConflict(w, response) 1057 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1058 - return 1059 - } 1060 - writeError(w, err.Error(), http.StatusInternalServerError) 1061 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1062 - } 1063 - 1064 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1065 - rev1 := chi.URLParam(r, "rev1") 1066 - rev1, _ = url.PathUnescape(rev1) 1067 - 1068 - rev2 := chi.URLParam(r, "rev2") 1069 - rev2, _ = url.PathUnescape(rev2) 1070 - 1071 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1072 - 1073 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1074 - gr, err := git.PlainOpen(path) 1075 - if err != nil { 1076 - notFound(w) 1077 - return 1078 - } 1079 - 1080 - commit1, err := gr.ResolveRevision(rev1) 1081 - if err != nil { 1082 - l.Error("error resolving revision 1", "msg", err.Error()) 1083 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1084 - return 1085 - } 1086 - 1087 - commit2, err := gr.ResolveRevision(rev2) 1088 - if err != nil { 1089 - l.Error("error resolving revision 2", "msg", err.Error()) 1090 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1091 - return 1092 - } 1093 - 1094 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1095 - if err != nil { 1096 - l.Error("error comparing revisions", "msg", err.Error()) 1097 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1098 - return 1099 - } 1100 - 1101 - writeJSON(w, types.RepoFormatPatchResponse{ 1102 - Rev1: commit1.Hash.String(), 1103 - Rev2: commit2.Hash.String(), 1104 - FormatPatch: formatPatch, 1105 - Patch: rawPatch, 1106 - }) 1107 - return 1108 - } 1109 - 1110 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1111 - l := h.l.With("handler", "NewHiddenRef") 1112 - 1113 - forkRef := chi.URLParam(r, "forkRef") 1114 - forkRef, _ = url.PathUnescape(forkRef) 1115 - 1116 - remoteRef := chi.URLParam(r, "remoteRef") 1117 - remoteRef, _ = url.PathUnescape(remoteRef) 1118 - 1119 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1120 - gr, err := git.PlainOpen(path) 1121 - if err != nil { 1122 - notFound(w) 1123 - return 1124 - } 1125 - 1126 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1127 - if err != nil { 1128 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1129 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1130 - return 1131 - } 1132 - 1133 - w.WriteHeader(http.StatusNoContent) 1134 - return 1135 - } 1136 - 1137 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1138 - l := h.l.With("handler", "AddMember") 1139 - 1140 - data := struct { 1141 - Did string `json:"did"` 1142 - }{} 1143 - 1144 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1145 - writeError(w, "invalid request body", http.StatusBadRequest) 1146 - return 1147 - } 1148 - 1149 - did := data.Did 1150 - 1151 - if err := h.db.AddDid(did); err != nil { 1152 - l.Error("adding did", "error", err.Error()) 1153 - writeError(w, err.Error(), http.StatusInternalServerError) 1154 - return 1155 - } 1156 - h.jc.AddDid(did) 1157 - 1158 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1159 - l.Error("adding member", "error", err.Error()) 1160 - writeError(w, err.Error(), http.StatusInternalServerError) 1161 - return 1162 - } 1163 - 1164 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1165 - l.Error("fetching and adding keys", "error", err.Error()) 1166 - writeError(w, err.Error(), http.StatusInternalServerError) 1167 - return 1168 - } 1169 - 1170 - w.WriteHeader(http.StatusNoContent) 1171 - } 1172 - 1173 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1174 - l := h.l.With("handler", "AddRepoCollaborator") 1175 - 1176 - data := struct { 1177 - Did string `json:"did"` 1178 - }{} 1179 - 1180 - ownerDid := chi.URLParam(r, "did") 1181 - repo := chi.URLParam(r, "name") 1182 - 1183 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1184 - writeError(w, "invalid request body", http.StatusBadRequest) 1185 - return 1186 - } 1187 - 1188 - if err := h.db.AddDid(data.Did); err != nil { 1189 - l.Error("adding did", "error", err.Error()) 1190 - writeError(w, err.Error(), http.StatusInternalServerError) 1191 - return 1192 - } 1193 - h.jc.AddDid(data.Did) 1194 - 1195 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1196 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1197 - l.Error("adding repo collaborator", "error", err.Error()) 1198 - writeError(w, err.Error(), http.StatusInternalServerError) 1199 - return 1200 - } 1201 - 1202 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1203 - l.Error("fetching and adding keys", "error", err.Error()) 1204 - writeError(w, err.Error(), http.StatusInternalServerError) 1205 - return 1206 - } 1207 - 1208 - w.WriteHeader(http.StatusNoContent) 1209 - } 1210 - 1211 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1212 - l := h.l.With("handler", "DefaultBranch") 1213 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1214 - 1215 - gr, err := git.Open(path, "") 1216 - if err != nil { 1217 - notFound(w) 1218 - return 1219 - } 1220 - 1221 - branch, err := gr.FindMainBranch() 1222 - if err != nil { 1223 - writeError(w, err.Error(), http.StatusInternalServerError) 1224 - l.Error("getting default branch", "error", err.Error()) 1225 - return 1226 - } 1227 - 1228 - writeJSON(w, types.RepoDefaultBranchResponse{ 1229 - Branch: branch, 1230 - }) 1231 - } 1232 - 1233 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1234 - l := h.l.With("handler", "SetDefaultBranch") 1235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1236 - 1237 - data := struct { 1238 - Branch string `json:"branch"` 1239 - }{} 1240 - 1241 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1242 - writeError(w, err.Error(), http.StatusBadRequest) 1243 - return 1244 - } 1245 - 1246 - gr, err := git.PlainOpen(path) 1247 - if err != nil { 1248 - notFound(w) 1249 - return 1250 - } 1251 - 1252 - err = gr.SetDefaultBranch(data.Branch) 1253 - if err != nil { 1254 - writeError(w, err.Error(), http.StatusInternalServerError) 1255 - l.Error("setting default branch", "error", err.Error()) 1256 - return 1257 - } 1258 - 1259 - w.WriteHeader(http.StatusNoContent) 1260 - } 1261 - 1262 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1263 - l := h.l.With("handler", "Init") 1264 - 1265 - if h.knotInitialized { 1266 - writeError(w, "knot already initialized", http.StatusConflict) 1267 - return 1268 - } 1269 - 1270 - data := struct { 1271 - Did string `json:"did"` 1272 - }{} 1273 - 1274 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1275 - l.Error("failed to decode request body", "error", err.Error()) 1276 - writeError(w, "invalid request body", http.StatusBadRequest) 1277 - return 1278 - } 1279 - 1280 - if data.Did == "" { 1281 - l.Error("empty DID in request", "did", data.Did) 1282 - writeError(w, "did is empty", http.StatusBadRequest) 1283 - return 1284 - } 1285 - 1286 - if err := h.db.AddDid(data.Did); err != nil { 1287 - l.Error("failed to add DID", "error", err.Error()) 1288 - writeError(w, err.Error(), http.StatusInternalServerError) 1289 - return 1290 - } 1291 - h.jc.AddDid(data.Did) 1292 - 1293 - if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1294 - l.Error("adding owner", "error", err.Error()) 1295 - writeError(w, err.Error(), http.StatusInternalServerError) 1296 - return 1297 - } 1298 - 1299 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1300 - l.Error("fetching and adding keys", "error", err.Error()) 1301 - writeError(w, err.Error(), http.StatusInternalServerError) 1302 - return 1303 - } 1304 - 1305 - close(h.init) 1306 - 1307 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1308 - mac.Write([]byte("ok")) 1309 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1310 - 1311 - w.WriteHeader(http.StatusNoContent) 1312 - } 1313 - 1314 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1315 - w.Write([]byte("ok")) 1316 - } 1317 - 1318 - func validateRepoName(name string) error { 1319 - // check for path traversal attempts 1320 - if name == "." || name == ".." || 1321 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1322 - return fmt.Errorf("Repository name contains invalid path characters") 1323 - } 1324 - 1325 - // check for sequences that could be used for traversal when normalized 1326 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1327 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1328 - return fmt.Errorf("Repository name contains invalid path sequence") 1329 - } 1330 - 1331 - // then continue with character validation 1332 - for _, char := range name { 1333 - if !((char >= 'a' && char <= 'z') || 1334 - (char >= 'A' && char <= 'Z') || 1335 - (char >= '0' && char <= '9') || 1336 - char == '-' || char == '_' || char == '.') { 1337 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1338 - } 1339 - } 1340 - 1341 - // additional check to prevent multiple sequential dots 1342 - if strings.Contains(name, "..") { 1343 - return fmt.Errorf("Repository name cannot contain sequential dots") 1344 - } 1345 - 1346 - // if all checks pass 1347 216 return nil 1348 217 }
+156
knotserver/xrpc/create_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "strings" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + gogit "github.com/go-git/go-git/v5" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 18 + "tangled.sh/tangled.sh/core/knotserver/git" 19 + "tangled.sh/tangled.sh/core/rbac" 20 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 21 + ) 22 + 23 + func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 24 + l := h.Logger.With("handler", "NewRepo") 25 + fail := func(e xrpcerr.XrpcError) { 26 + l.Error("failed", "kind", e.Tag, "error", e.Message) 27 + writeError(w, e, http.StatusBadRequest) 28 + } 29 + 30 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 31 + if !ok { 32 + fail(xrpcerr.MissingActorDidError) 33 + return 34 + } 35 + 36 + isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + if !isMember { 42 + fail(xrpcerr.AccessControlError(actorDid.String())) 43 + return 44 + } 45 + 46 + var data tangled.RepoCreate_Input 47 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + rkey := data.Rkey 53 + 54 + ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) 55 + if err != nil || ident.Handle.IsInvalidHandle() { 56 + fail(xrpcerr.GenericError(err)) 57 + return 58 + } 59 + 60 + xrpcc := xrpc.Client{ 61 + Host: ident.PDSEndpoint(), 62 + } 63 + 64 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(err)) 67 + return 68 + } 69 + 70 + repo := resp.Value.Val.(*tangled.Repo) 71 + 72 + defaultBranch := h.Config.Repo.MainBranch 73 + if data.DefaultBranch != nil && *data.DefaultBranch != "" { 74 + defaultBranch = *data.DefaultBranch 75 + } 76 + 77 + if err := validateRepoName(repo.Name); err != nil { 78 + l.Error("creating repo", "error", err.Error()) 79 + fail(xrpcerr.GenericError(err)) 80 + return 81 + } 82 + 83 + relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) 84 + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 + 86 + if data.Source != nil && *data.Source != "" { 87 + err = git.Fork(repoPath, *data.Source) 88 + if err != nil { 89 + l.Error("forking repo", "error", err.Error()) 90 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 + return 92 + } 93 + } else { 94 + err = git.InitBare(repoPath, defaultBranch) 95 + if err != nil { 96 + l.Error("initializing bare repo", "error", err.Error()) 97 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 98 + fail(xrpcerr.RepoExistsError("repository already exists")) 99 + return 100 + } else { 101 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 + return 103 + } 104 + } 105 + } 106 + 107 + // add perms for this user to access the repo 108 + err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath) 109 + if err != nil { 110 + l.Error("adding repo permissions", "error", err.Error()) 111 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + hook.SetupRepo( 116 + hook.Config( 117 + hook.WithScanPath(h.Config.Repo.ScanPath), 118 + hook.WithInternalApi(h.Config.Server.InternalListenAddr), 119 + ), 120 + repoPath, 121 + ) 122 + 123 + w.WriteHeader(http.StatusOK) 124 + } 125 + 126 + func validateRepoName(name string) error { 127 + // check for path traversal attempts 128 + if name == "." || name == ".." || 129 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 130 + return fmt.Errorf("Repository name contains invalid path characters") 131 + } 132 + 133 + // check for sequences that could be used for traversal when normalized 134 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 135 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 136 + return fmt.Errorf("Repository name contains invalid path sequence") 137 + } 138 + 139 + // then continue with character validation 140 + for _, char := range name { 141 + if !((char >= 'a' && char <= 'z') || 142 + (char >= 'A' && char <= 'Z') || 143 + (char >= '0' && char <= '9') || 144 + char == '-' || char == '_' || char == '.') { 145 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 146 + } 147 + } 148 + 149 + // additional check to prevent multiple sequential dots 150 + if strings.Contains(name, "..") { 151 + return fmt.Errorf("Repository name cannot contain sequential dots") 152 + } 153 + 154 + // if all checks pass 155 + return nil 156 + }
+96
knotserver/xrpc/delete_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/rbac" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "DeleteRepo") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDelete_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + rkey := data.Rkey 41 + 42 + if did == "" || name == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 + return 45 + } 46 + 47 + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 + if err != nil || ident.Handle.IsInvalidHandle() { 49 + fail(xrpcerr.GenericError(err)) 50 + return 51 + } 52 + 53 + xrpcc := xrpc.Client{ 54 + Host: ident.PDSEndpoint(), 55 + } 56 + 57 + // ensure that the record does not exists 58 + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 + if err == nil { 60 + fail(xrpcerr.RecordExistsError(rkey)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + if !isDeleteAllowed { 71 + fail(xrpcerr.AccessControlError(actorDid.String())) 72 + return 73 + } 74 + 75 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = os.RemoveAll(repoPath) 82 + if err != nil { 83 + l.Error("deleting repo", "error", err.Error()) 84 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 + if err != nil { 90 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + w.WriteHeader(http.StatusOK) 96 + }
+111
knotserver/xrpc/fork_status.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/types" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkStatus") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoForkStatus_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + did := data.Did 38 + source := data.Source 39 + branch := data.Branch 40 + hiddenRef := data.HiddenRef 41 + 42 + if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 + return 45 + } 46 + 47 + var name string 48 + if data.Name != "" { 49 + name = data.Name 50 + } else { 51 + name = filepath.Base(source) 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + 56 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + gr, err := git.PlainOpen(repoPath) 69 + if err != nil { 70 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 + return 72 + } 73 + 74 + forkCommit, err := gr.ResolveRevision(branch) 75 + if err != nil { 76 + l.Error("error resolving ref revision", "msg", err.Error()) 77 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 + return 79 + } 80 + 81 + sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 + if err != nil { 83 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 + return 86 + } 87 + 88 + status := types.UpToDate 89 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 + if err != nil { 92 + l.Error("error checking ancestor relationship", "error", err.Error()) 93 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 + return 95 + } 96 + 97 + if isAncestor { 98 + status = types.FastForwardable 99 + } else { 100 + status = types.Conflict 101 + } 102 + } 103 + 104 + response := tangled.RepoForkStatus_Output{ 105 + Status: int64(status), 106 + } 107 + 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(http.StatusOK) 110 + json.NewEncoder(w).Encode(response) 111 + }
+73
knotserver/xrpc/fork_sync.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "ForkSync") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoForkSync_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + did := data.Did 37 + name := data.Name 38 + branch := data.Branch 39 + 40 + if did == "" || name == "" { 41 + fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) 42 + return 43 + } 44 + 45 + relativeRepoPath := filepath.Join(did, name) 46 + 47 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 + return 51 + } 52 + 53 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + gr, err := git.Open(repoPath, branch) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 + return 63 + } 64 + 65 + err = gr.Sync() 66 + if err != nil { 67 + l.Error("error syncing repo fork", "error", err.Error()) 68 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + }
+104
knotserver/xrpc/hidden_ref.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+112
knotserver/xrpc/merge.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/types" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "Merge") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoMerge_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + 41 + if did == "" || name == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 + return 44 + } 45 + 46 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 + if err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + gr, err := git.Open(repoPath, data.Branch) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 + return 68 + } 69 + 70 + mo := &git.MergeOptions{} 71 + if data.AuthorName != nil { 72 + mo.AuthorName = *data.AuthorName 73 + } 74 + if data.AuthorEmail != nil { 75 + mo.AuthorEmail = *data.AuthorEmail 76 + } 77 + if data.CommitBody != nil { 78 + mo.CommitBody = *data.CommitBody 79 + } 80 + if data.CommitMessage != nil { 81 + mo.CommitMessage = *data.CommitMessage 82 + } 83 + 84 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 + 86 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 87 + if err != nil { 88 + var mergeErr *git.ErrMerge 89 + if errors.As(err, &mergeErr) { 90 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 91 + for i, conflict := range mergeErr.Conflicts { 92 + conflicts[i] = types.ConflictInfo{ 93 + Filename: conflict.Filename, 94 + Reason: conflict.Reason, 95 + } 96 + } 97 + 98 + conflictErr := xrpcerr.NewXrpcError( 99 + xrpcerr.WithTag("MergeConflict"), 100 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 101 + ) 102 + writeError(w, conflictErr, http.StatusConflict) 103 + return 104 + } else { 105 + l.Error("failed to merge", "error", err.Error()) 106 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 107 + return 108 + } 109 + } 110 + 111 + w.WriteHeader(http.StatusOK) 112 + }
+87
knotserver/xrpc/merge_check.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger.With("handler", "MergeCheck") 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + 22 + var data tangled.RepoMergeCheck_Input 23 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 24 + fail(xrpcerr.GenericError(err)) 25 + return 26 + } 27 + 28 + did := data.Did 29 + name := data.Name 30 + 31 + if did == "" || name == "" { 32 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 33 + return 34 + } 35 + 36 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + 42 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 43 + if err != nil { 44 + fail(xrpcerr.GenericError(err)) 45 + return 46 + } 47 + 48 + gr, err := git.Open(repoPath, data.Branch) 49 + if err != nil { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 51 + return 52 + } 53 + 54 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 + 56 + response := tangled.RepoMergeCheck_Output{ 57 + Is_conflicted: false, 58 + } 59 + 60 + if err != nil { 61 + var mergeErr *git.ErrMerge 62 + if errors.As(err, &mergeErr) { 63 + response.Is_conflicted = true 64 + 65 + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 66 + for i, conflict := range mergeErr.Conflicts { 67 + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 68 + Filename: conflict.Filename, 69 + Reason: conflict.Reason, 70 + } 71 + } 72 + response.Conflicts = conflicts 73 + 74 + if mergeErr.Message != "" { 75 + response.Message = &mergeErr.Message 76 + } 77 + } else { 78 + response.Is_conflicted = true 79 + errMsg := err.Error() 80 + response.Error = &errMsg 81 + } 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + w.WriteHeader(http.StatusOK) 86 + json.NewEncoder(w).Encode(response) 87 + }
-149
knotserver/xrpc/router.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "log/slog" 8 - "net/http" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/jetstream" 14 - "tangled.sh/tangled.sh/core/knotserver/config" 15 - "tangled.sh/tangled.sh/core/knotserver/db" 16 - "tangled.sh/tangled.sh/core/notifier" 17 - "tangled.sh/tangled.sh/core/rbac" 18 - 19 - "github.com/bluesky-social/indigo/atproto/auth" 20 - "github.com/go-chi/chi/v5" 21 - ) 22 - 23 - type Xrpc struct { 24 - Config *config.Config 25 - Db *db.DB 26 - Ingester *jetstream.JetstreamClient 27 - Enforcer *rbac.Enforcer 28 - Logger *slog.Logger 29 - Notifier *notifier.Notifier 30 - Resolver *idresolver.Resolver 31 - } 32 - 33 - func (x *Xrpc) Router() http.Handler { 34 - r := chi.NewRouter() 35 - 36 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 - 38 - return r 39 - } 40 - 41 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 - l := x.Logger.With("url", r.URL) 44 - 45 - token := r.Header.Get("Authorization") 46 - token = strings.TrimPrefix(token, "Bearer ") 47 - 48 - s := auth.ServiceAuthValidator{ 49 - Audience: x.Config.Server.Did().String(), 50 - Dir: x.Resolver.Directory(), 51 - } 52 - 53 - did, err := s.Validate(r.Context(), token, nil) 54 - if err != nil { 55 - l.Error("signature verification failed", "err", err) 56 - writeError(w, AuthError(err), http.StatusForbidden) 57 - return 58 - } 59 - 60 - r = r.WithContext( 61 - context.WithValue(r.Context(), ActorDid, did), 62 - ) 63 - 64 - next.ServeHTTP(w, r) 65 - }) 66 - } 67 - 68 - type XrpcError struct { 69 - Tag string `json:"error"` 70 - Message string `json:"message"` 71 - } 72 - 73 - func NewXrpcError(opts ...ErrOpt) XrpcError { 74 - x := XrpcError{} 75 - for _, o := range opts { 76 - o(&x) 77 - } 78 - 79 - return x 80 - } 81 - 82 - type ErrOpt = func(xerr *XrpcError) 83 - 84 - func WithTag(tag string) ErrOpt { 85 - return func(xerr *XrpcError) { 86 - xerr.Tag = tag 87 - } 88 - } 89 - 90 - func WithMessage[S ~string](s S) ErrOpt { 91 - return func(xerr *XrpcError) { 92 - xerr.Message = string(s) 93 - } 94 - } 95 - 96 - func WithError(e error) ErrOpt { 97 - return func(xerr *XrpcError) { 98 - xerr.Message = e.Error() 99 - } 100 - } 101 - 102 - var MissingActorDidError = NewXrpcError( 103 - WithTag("MissingActorDid"), 104 - WithMessage("actor DID not supplied"), 105 - ) 106 - 107 - var AuthError = func(err error) XrpcError { 108 - return NewXrpcError( 109 - WithTag("Auth"), 110 - WithError(fmt.Errorf("signature verification failed: %w", err)), 111 - ) 112 - } 113 - 114 - var InvalidRepoError = func(r string) XrpcError { 115 - return NewXrpcError( 116 - WithTag("InvalidRepo"), 117 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 - ) 119 - } 120 - 121 - var AccessControlError = func(d string) XrpcError { 122 - return NewXrpcError( 123 - WithTag("AccessControl"), 124 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 - ) 126 - } 127 - 128 - var GitError = func(e error) XrpcError { 129 - return NewXrpcError( 130 - WithTag("Git"), 131 - WithError(fmt.Errorf("git error: %w", e)), 132 - ) 133 - } 134 - 135 - func GenericError(err error) XrpcError { 136 - return NewXrpcError( 137 - WithTag("Generic"), 138 - WithError(err), 139 - ) 140 - } 141 - 142 - // this is slightly different from http_util::write_error to follow the spec: 143 - // 144 - // the json object returned must include an "error" and a "message" 145 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 - w.Header().Set("Content-Type", "application/json") 147 - w.WriteHeader(status) 148 - json.NewEncoder(w).Encode(e) 149 - }
+12 -10
knotserver/xrpc/set_default_branch.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/knotserver/git" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 + 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 17 ) 16 18 17 19 const ActorDid string = "ActorDid" 18 20 19 21 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 22 l := x.Logger 21 - fail := func(e XrpcError) { 23 + fail := func(e xrpcerr.XrpcError) { 22 24 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 25 writeError(w, e, http.StatusBadRequest) 24 26 } 25 27 26 28 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 29 if !ok { 28 - fail(MissingActorDidError) 30 + fail(xrpcerr.MissingActorDidError) 29 31 return 30 32 } 31 33 32 34 var data tangled.RepoSetDefaultBranch_Input 33 35 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(GenericError(err)) 36 + fail(xrpcerr.GenericError(err)) 35 37 return 36 38 } 37 39 38 40 // unfortunately we have to resolve repo-at here 39 41 repoAt, err := syntax.ParseATURI(data.Repo) 40 42 if err != nil { 41 - fail(InvalidRepoError(data.Repo)) 43 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 44 return 43 45 } 44 46 45 47 // resolve this aturi to extract the repo record 46 48 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 49 if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 51 return 50 52 } 51 53 52 54 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 55 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 56 if err != nil { 55 - fail(GenericError(err)) 57 + fail(xrpcerr.GenericError(err)) 56 58 return 57 59 } 58 60 59 61 repo := resp.Value.Val.(*tangled.Repo) 60 62 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 63 if err != nil { 62 - fail(GenericError(err)) 64 + fail(xrpcerr.GenericError(err)) 63 65 return 64 66 } 65 67 66 68 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 69 l.Error("insufficent permissions", "did", actorDid.String()) 68 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 70 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 71 return 70 72 } 71 73 72 74 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 75 gr, err := git.PlainOpen(path) 74 76 if err != nil { 75 - fail(InvalidRepoError(data.Repo)) 77 + fail(xrpcerr.GenericError(err)) 76 78 return 77 79 } 78 80 79 81 err = gr.SetDefaultBranch(data.DefaultBranch) 80 82 if err != nil { 81 83 l.Error("setting default branch", "error", err.Error()) 82 - writeError(w, GitError(err), http.StatusInternalServerError) 84 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 85 return 84 86 } 85 87
+60
knotserver/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/idresolver" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/config" 12 + "tangled.sh/tangled.sh/core/knotserver/db" 13 + "tangled.sh/tangled.sh/core/notifier" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 17 + 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + type Xrpc struct { 22 + Config *config.Config 23 + Db *db.DB 24 + Ingester *jetstream.JetstreamClient 25 + Enforcer *rbac.Enforcer 26 + Logger *slog.Logger 27 + Notifier *notifier.Notifier 28 + Resolver *idresolver.Resolver 29 + ServiceAuth *serviceauth.ServiceAuth 30 + } 31 + 32 + func (x *Xrpc) Router() http.Handler { 33 + r := chi.NewRouter() 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(x.ServiceAuth.VerifyServiceAuth) 37 + 38 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 39 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 40 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 41 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 43 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 44 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 45 + }) 46 + 47 + // merge check is an open endpoint 48 + // 49 + // TODO: should we constrain this more? 50 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 + // - use ETags on clients to keep requests to a minimum 52 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 + return r 54 + } 55 + 56 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 + w.Header().Set("Content-Type", "application/json") 58 + w.WriteHeader(status) 59 + json.NewEncoder(w).Encode(e) 60 + }
+59 -52
lexicons/git/refUpdate.json
··· 51 51 "maxLength": 40 52 52 }, 53 53 "meta": { 54 - "type": "object", 55 - "required": [ 56 - "isDefaultRef", 57 - "commitCount" 58 - ], 59 - "properties": { 60 - "isDefaultRef": { 61 - "type": "boolean", 62 - "default": "false" 63 - }, 64 - "langBreakdown": { 65 - "type": "object", 66 - "properties": { 67 - "inputs": { 68 - "type": "array", 69 - "items": { 70 - "type": "ref", 71 - "ref": "#pair" 72 - } 73 - } 74 - } 75 - }, 76 - "commitCount": { 77 - "type": "object", 78 - "required": [], 79 - "properties": { 80 - "byEmail": { 81 - "type": "array", 82 - "items": { 83 - "type": "object", 84 - "required": [ 85 - "email", 86 - "count" 87 - ], 88 - "properties": { 89 - "email": { 90 - "type": "string" 91 - }, 92 - "count": { 93 - "type": "integer" 94 - } 95 - } 96 - } 97 - } 98 - } 99 - } 100 - } 54 + "type": "ref", 55 + "ref": "#meta" 56 + } 57 + } 58 + } 59 + }, 60 + "meta": { 61 + "type": "object", 62 + "required": ["isDefaultRef", "commitCount"], 63 + "properties": { 64 + "isDefaultRef": { 65 + "type": "boolean", 66 + "default": false 67 + }, 68 + "langBreakdown": { 69 + "type": "ref", 70 + "ref": "#langBreakdown" 71 + }, 72 + "commitCount": { 73 + "type": "ref", 74 + "ref": "#commitCountBreakdown" 75 + } 76 + } 77 + }, 78 + "langBreakdown": { 79 + "type": "object", 80 + "properties": { 81 + "inputs": { 82 + "type": "array", 83 + "items": { 84 + "type": "ref", 85 + "ref": "#individualLanguageSize" 101 86 } 102 87 } 103 88 } 104 89 }, 105 - "pair": { 90 + "individualLanguageSize": { 106 91 "type": "object", 107 - "required": [ 108 - "lang", 109 - "size" 110 - ], 92 + "required": ["lang", "size"], 111 93 "properties": { 112 94 "lang": { 113 95 "type": "string" 114 96 }, 115 97 "size": { 98 + "type": "integer" 99 + } 100 + } 101 + }, 102 + "commitCountBreakdown": { 103 + "type": "object", 104 + "required": [], 105 + "properties": { 106 + "byEmail": { 107 + "type": "array", 108 + "items": { 109 + "type": "ref", 110 + "ref": "#individualEmailCommitCount" 111 + } 112 + } 113 + } 114 + }, 115 + "individualEmailCommitCount": { 116 + "type": "object", 117 + "required": ["email", "count"], 118 + "properties": { 119 + "email": { 120 + "type": "string" 121 + }, 122 + "count": { 116 123 "type": "integer" 117 124 } 118 125 }
+1 -8
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "issue", 14 - "body", 15 - "createdAt" 16 - ], 12 + "required": ["issue", "body", "createdAt"], 17 13 "properties": { 18 14 "issue": { 19 15 "type": "string", ··· 22 18 "repo": { 23 19 "type": "string", 24 20 "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 21 }, 29 22 "owner": { 30 23 "type": "string",
+1 -14
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 - }, 27 - "owner": { 28 - "type": "string", 29 - "format": "did" 30 17 }, 31 18 "title": { 32 19 "type": "string"
+24
lexicons/knot/knot.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+7 -63
lexicons/pipeline/pipeline.json
··· 149 149 "type": "object", 150 150 "required": [ 151 151 "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 152 + "engine", 153 + "clone", 154 + "raw" 156 155 ], 157 156 "properties": { 158 157 "name": { 159 158 "type": "string" 160 159 }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 160 + "engine": { 161 + "type": "string" 181 162 }, 182 163 "clone": { 183 164 "type": "ref", 184 165 "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 166 + }, 167 + "raw": { 196 168 "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 169 } 204 170 } 205 171 }, ··· 219 185 }, 220 186 "submodules": { 221 187 "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 188 } 245 189 } 246 190 },
-11
lexicons/pulls/comment.json
··· 19 19 "type": "string", 20 20 "format": "at-uri" 21 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 22 "body": { 34 23 "type": "string" 35 24 },
+20 -12
lexicons/pulls/pull.json
··· 10 10 "record": { 11 11 "type": "object", 12 12 "required": [ 13 - "targetRepo", 14 - "targetBranch", 15 - "pullId", 13 + "target", 16 14 "title", 17 15 "patch", 18 16 "createdAt" 19 17 ], 20 18 "properties": { 21 - "targetRepo": { 22 - "type": "string", 23 - "format": "at-uri" 24 - }, 25 - "targetBranch": { 26 - "type": "string" 27 - }, 28 - "pullId": { 29 - "type": "integer" 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 30 22 }, 31 23 "title": { 32 24 "type": "string" ··· 45 37 "type": "string", 46 38 "format": "datetime" 47 39 } 40 + } 41 + } 42 + }, 43 + "target": { 44 + "type": "object", 45 + "required": [ 46 + "repo", 47 + "branch" 48 + ], 49 + "properties": { 50 + "repo": { 51 + "type": "string", 52 + "format": "at-uri" 53 + }, 54 + "branch": { 55 + "type": "string" 48 56 } 49 57 } 50 58 },
+33
lexicons/repo/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+32
lexicons/repo/delete.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "rkey"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+53
lexicons/repo/forkStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkStatus", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check fork status relative to upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "source", "branch", "hiddenRef"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the fork owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the forked repository" 22 + }, 23 + "source": { 24 + "type": "string", 25 + "description": "Source repository URL" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Branch to check status for" 30 + }, 31 + "hiddenRef": { 32 + "type": "string", 33 + "description": "Hidden ref to use for comparison" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["status"], 43 + "properties": { 44 + "status": { 45 + "type": "integer", 46 + "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+42
lexicons/repo/forkSync.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Sync a forked repository with its upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "did", 14 + "source", 15 + "name", 16 + "branch" 17 + ], 18 + "properties": { 19 + "did": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the fork owner" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "AT-URI of the source repository" 28 + }, 29 + "name": { 30 + "type": "string", 31 + "description": "Name of the forked repository" 32 + }, 33 + "branch": { 34 + "type": "string", 35 + "description": "Branch to sync" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+59
lexicons/repo/hiddenRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+52
lexicons/repo/merge.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+3 -1
log/log.go
··· 9 9 // NewHandler sets up a new slog.Handler with the service name 10 10 // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 13 15 14 16 var attrs []slog.Attr 15 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+8 -2
nix/gomod2nix.toml
··· 181 181 [mod."github.com/gorilla/css"] 182 182 version = "v1.0.1" 183 183 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 184 187 [mod."github.com/gorilla/securecookie"] 185 188 version = "v1.1.2" 186 189 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" ··· 423 426 version = "v0.3.1" 424 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 425 428 [mod."github.com/yuin/goldmark"] 426 - version = "v1.4.13" 427 - hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI=" 429 + version = "v1.4.15" 430 + hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 431 + [mod."github.com/yuin/goldmark-highlighting/v2"] 432 + version = "v2.0.0-20230729083705-37449abec8cc" 433 + hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 428 434 [mod."gitlab.com/yawning/secp256k1-voi"] 429 435 version = "v0.0.0-20230925100816-f2616030848b" 430 436 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
+14
nix/modules/appview.nix
··· 27 27 default = "00000000000000000000000000000000"; 28 28 description = "Cookie secret"; 29 29 }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 30 43 }; 31 44 }; 32 45 ··· 39 52 ListenStream = "0.0.0.0:${toString cfg.port}"; 40 53 ExecStart = "${cfg.package}/bin/appview"; 41 54 Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 42 56 }; 43 57 44 58 environment = {
+32 -29
nix/modules/knot.nix
··· 93 93 description = "Internal address for inter-service communication"; 94 94 }; 95 95 96 - secretFile = mkOption { 97 - type = lib.types.path; 98 - example = "KNOT_SERVER_SECRET=<hash>"; 99 - description = "File containing secret key provided by appview (required)"; 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (required)"; 100 100 }; 101 101 102 102 dbPath = mkOption { ··· 126 126 cfg.package 127 127 ]; 128 128 129 - system.activationScripts.gitConfig = let 130 - setMotd = 131 - if cfg.motdFile != null && cfg.motd != null 132 - then throw "motdFile and motd cannot be both set" 133 - else '' 134 - ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 135 - ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 136 - ''; 137 - in '' 138 - mkdir -p "${cfg.repo.scanPath}" 139 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 140 - 141 - mkdir -p "${cfg.stateDir}/.config/git" 142 - cat > "${cfg.stateDir}/.config/git/config" << EOF 143 - [user] 144 - name = Git User 145 - email = git@example.com 146 - [receive] 147 - advertisePushOptions = true 148 - EOF 149 - ${setMotd} 150 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 151 - ''; 152 - 153 129 users.users.${cfg.gitUser} = { 154 130 isSystemUser = true; 155 131 useDefaultShell = true; ··· 185 161 description = "knot service"; 186 162 after = ["network.target" "sshd.service"]; 187 163 wantedBy = ["multi-user.target"]; 164 + enableStrictShellChecks = true; 165 + 166 + preStart = let 167 + setMotd = 168 + if cfg.motdFile != null && cfg.motd != null 169 + then throw "motdFile and motd cannot be both set" 170 + else '' 171 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 172 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 173 + ''; 174 + in '' 175 + mkdir -p "${cfg.repo.scanPath}" 176 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 177 + 178 + mkdir -p "${cfg.stateDir}/.config/git" 179 + cat > "${cfg.stateDir}/.config/git/config" << EOF 180 + [user] 181 + name = Git User 182 + email = git@example.com 183 + [receive] 184 + advertisePushOptions = true 185 + EOF 186 + ${setMotd} 187 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 188 + ''; 189 + 188 190 serviceConfig = { 189 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 190 193 WorkingDirectory = cfg.stateDir; 191 194 Environment = [ 192 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" ··· 196 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 197 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 198 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 199 203 ]; 200 - EnvironmentFile = cfg.server.secretFile; 201 204 ExecStart = "${cfg.package}/bin/knot server"; 202 205 Restart = "always"; 203 206 };
+18 -2
nix/modules/spindle.nix
··· 55 55 description = "DID of owner (required)"; 56 56 }; 57 57 58 + maxJobCount = mkOption { 59 + type = types.int; 60 + default = 2; 61 + example = 5; 62 + description = "Maximum number of concurrent jobs to run"; 63 + }; 64 + 65 + queueSize = mkOption { 66 + type = types.int; 67 + default = 100; 68 + example = 100; 69 + description = "Maximum number of jobs queue up"; 70 + }; 71 + 58 72 secrets = { 59 73 provider = mkOption { 60 74 type = types.str; ··· 108 122 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 109 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 110 124 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 + "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" 126 + "SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}" 111 127 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 128 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 129 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 114 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 130 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 131 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 116 132 ]; 117 133 ExecStart = "${cfg.package}/bin/spindle"; 118 134 Restart = "always";
+8 -2
nix/pkgs/appview-static-files.nix
··· 9 9 tailwindcss, 10 10 src, 11 11 }: 12 - runCommandLocal "appview-static-files" {} '' 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 13 19 mkdir -p $out/{fonts,icons} && cd $out 14 20 cp -f ${htmx-src} htmx.min.js 15 21 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 16 22 cp -rf ${lucide-src}/*.svg icons/ 17 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 18 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 19 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 20 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 21 27 # for whatever reason (produces broken css), so we are doing this instead 22 28 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+8 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - src, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - inherit src modules; 10 - subPackages = ["cmd/genjwks"]; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 15 + inherit modules; 11 16 doCheck = false; 12 17 CGO_ENABLED = 0; 13 18 }
+57 -16
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 3 system, 4 + hostSystem, 4 5 self, 5 6 }: let 6 7 envVar = name: let ··· 16 17 self.nixosModules.knot 17 18 self.nixosModules.spindle 18 19 ({ 20 + lib, 19 21 config, 20 22 pkgs, 21 23 ... 22 24 }: { 23 - nixos-shell = { 24 - inheritPath = false; 25 - mounts = { 26 - mountHome = false; 27 - mountNixProfile = false; 28 - }; 29 - }; 30 - virtualisation = { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 31 29 memorySize = 2048; 32 30 diskSize = 10 * 1024; 33 31 cores = 2; ··· 51 49 guest.port = 6555; 52 50 } 53 51 ]; 52 + sharedDirectories = { 53 + # We can't use the 9p mounts directly for most of these 54 + # as SQLite is incompatible with them. So instead we 55 + # mount the shared directories to a different location 56 + # and copy the contents around on service start/stop. 57 + knotData = { 58 + source = "$TANGLED_VM_DATA_DIR/knot"; 59 + target = "/mnt/knot-data"; 60 + }; 61 + spindleData = { 62 + source = "$TANGLED_VM_DATA_DIR/spindle"; 63 + target = "/mnt/spindle-data"; 64 + }; 65 + spindleLogs = { 66 + source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 67 + target = "/var/log/spindle"; 68 + }; 69 + }; 54 70 }; 71 + # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 + networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 55 74 services.getty.autologinUser = "root"; 56 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 57 - systemd.tmpfiles.rules = let 58 - u = config.services.tangled-knot.gitUser; 59 - g = config.services.tangled-knot.gitUser; 60 - in [ 61 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 62 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}" 63 - ]; 64 76 services.tangled-knot = { 65 77 enable = true; 66 78 motd = "Welcome to the development knot!\n"; 67 79 server = { 68 - secretFile = "/var/lib/knot/secret"; 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 69 81 hostname = "localhost:6000"; 70 82 listenAddr = "0.0.0.0:6000"; 71 83 }; ··· 77 89 hostname = "localhost:6555"; 78 90 listenAddr = "0.0.0.0:6555"; 79 91 dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 80 94 secrets = { 81 95 provider = "sqlite"; 82 96 }; 83 97 }; 98 + }; 99 + users = { 100 + # So we don't have to deal with permission clashing between 101 + # blank disk VMs and existing state 102 + users.${config.services.tangled-knot.gitUser}.uid = 666; 103 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 104 + 105 + # TODO: separate spindle user 106 + }; 107 + systemd.services = let 108 + mkDataSyncScripts = source: target: { 109 + enableStrictShellChecks = true; 110 + 111 + preStart = lib.mkBefore '' 112 + mkdir -p ${target} 113 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 114 + ''; 115 + 116 + postStop = lib.mkAfter '' 117 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 118 + ''; 119 + 120 + serviceConfig.PermissionsStartOnly = true; 121 + }; 122 + in { 123 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 84 125 }; 85 126 }) 86 127 ];
+14 -1
rbac/rbac.go
··· 43 43 return nil, err 44 44 } 45 45 46 - db, err := sql.Open("sqlite3", path) 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 47 47 if err != nil { 48 48 return nil, err 49 49 } ··· 97 97 func (e *Enforcer) RemoveSpindle(spindle string) error { 98 98 spindle = intoSpindle(spindle) 99 99 _, err := e.E.DeleteDomains(spindle) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) RemoveKnot(knot string) error { 104 + _, err := e.E.DeleteDomains(knot) 100 105 return err 101 106 } 102 107 ··· 270 275 271 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 272 277 return e.isInviteAllowed(user, intoSpindle(domain)) 278 + } 279 + 280 + func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 + return e.E.Enforce(user, domain, domain, "repo:create") 282 + } 283 + 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 273 286 } 274 287 275 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+1 -1
rbac/rbac_test.go
··· 14 14 ) 15 15 16 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 18 assert.NoError(t, err) 19 19 20 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+6 -4
spindle/config/config.go
··· 16 16 Dev bool `env:"DEV, default=false"` 17 17 Owner string `env:"OWNER, required"` 18 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 + QueueSize int `env:"QUEUE_SIZE, default=100"` 21 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 19 22 } 20 23 21 24 func (s Server) Did() syntax.DID { ··· 32 35 Mount string `env:"MOUNT, default=spindle"` 33 36 } 34 37 35 - type Pipelines struct { 38 + type NixeryPipelines struct { 36 39 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 37 40 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 38 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 39 41 } 40 42 41 43 type Config struct { 42 - Server Server `env:",prefix=SPINDLE_SERVER_"` 43 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 44 + Server Server `env:",prefix=SPINDLE_SERVER_"` 45 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 44 46 } 45 47 46 48 func Load(ctx context.Context) (*Config, error) {
+14 -10
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 28 31 32 + _, err = db.Exec(` 29 33 create table if not exists _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null
-21
spindle/engine/ansi_stripper.go
··· 1 - package engine 2 - 3 - import ( 4 - "io" 5 - 6 - "regexp" 7 - ) 8 - 9 - // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 - const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 - 12 - var re = regexp.MustCompile(ansi) 13 - 14 - type ansiStrippingWriter struct { 15 - underlying io.Writer 16 - } 17 - 18 - func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 - clean := re.ReplaceAll(p, []byte{}) 20 - return w.underlying.Write(clean) 21 - }
+68 -415
spindle/engine/engine.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 7 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 8 14 9 securejoin "github.com/cyphar/filepath-securejoin" 15 - "github.com/docker/docker/api/types/container" 16 - "github.com/docker/docker/api/types/image" 17 - "github.com/docker/docker/api/types/mount" 18 - "github.com/docker/docker/api/types/network" 19 - "github.com/docker/docker/api/types/volume" 20 - "github.com/docker/docker/client" 21 - "github.com/docker/docker/pkg/stdcopy" 22 10 "golang.org/x/sync/errgroup" 23 - "tangled.sh/tangled.sh/core/log" 24 11 "tangled.sh/tangled.sh/core/notifier" 25 12 "tangled.sh/tangled.sh/core/spindle/config" 26 13 "tangled.sh/tangled.sh/core/spindle/db" ··· 28 15 "tangled.sh/tangled.sh/core/spindle/secrets" 29 16 ) 30 17 31 - const ( 32 - workspaceDir = "/tangled/workspace" 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 33 21 ) 34 22 35 - type cleanupFunc func(context.Context) error 36 - 37 - type Engine struct { 38 - docker client.APIClient 39 - l *slog.Logger 40 - db *db.DB 41 - n *notifier.Notifier 42 - cfg *config.Config 43 - vault secrets.Manager 44 - 45 - cleanupMu sync.Mutex 46 - cleanup map[string][]cleanupFunc 47 - } 48 - 49 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 50 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 - if err != nil { 52 - return nil, err 53 - } 54 - 55 - l := log.FromContext(ctx).With("component", "spindle") 56 - 57 - e := &Engine{ 58 - docker: dcli, 59 - l: l, 60 - db: db, 61 - n: n, 62 - cfg: cfg, 63 - vault: vault, 64 - } 65 - 66 - e.cleanup = make(map[string][]cleanupFunc) 67 - 68 - return e, nil 69 - } 70 - 71 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 72 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 23 + func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 24 + l.Info("starting all workflows in parallel", "pipeline", pipelineId) 73 25 74 26 // extract secrets 75 27 var allSecrets []secrets.UnlockedSecret 76 28 if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 - if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 30 allSecrets = res 79 31 } 80 32 } 81 33 82 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 83 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 84 - if err != nil { 85 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 86 - workflowTimeout = 5 * time.Minute 87 - } 88 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 89 - 90 34 eg, ctx := errgroup.WithContext(ctx) 91 - for _, w := range pipeline.Workflows { 92 - eg.Go(func() error { 93 - wid := models.WorkflowId{ 94 - PipelineId: pipelineId, 95 - Name: w.Name, 96 - } 97 - 98 - err := e.db.StatusRunning(wid, e.n) 99 - if err != nil { 100 - return err 101 - } 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 102 38 103 - err = e.SetupWorkflow(ctx, wid) 104 - if err != nil { 105 - e.l.Error("setting up worklow", "wid", wid, "err", err) 106 - return err 107 - } 108 - defer e.DestroyWorkflow(ctx, wid) 109 - 110 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 111 - if err != nil { 112 - e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error()) 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 113 45 114 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 46 + err := db.StatusRunning(wid, n) 115 47 if err != nil { 116 48 return err 117 49 } 118 50 119 - return fmt.Errorf("pulling image: %w", err) 120 - } 121 - defer reader.Close() 122 - io.Copy(os.Stdout, reader) 123 - 124 - ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 - defer cancel() 51 + err = eng.SetupWorkflow(ctx, wid, &w) 52 + if err != nil { 53 + // TODO(winter): Should this always set StatusFailed? 54 + // In the original, we only do in a subset of cases. 55 + l.Error("setting up worklow", "wid", wid, "err", err) 126 56 127 - err = e.StartSteps(ctx, wid, w, allSecrets) 128 - if err != nil { 129 - if errors.Is(err, ErrTimedOut) { 130 - dbErr := e.db.StatusTimeout(wid, e.n) 131 - if dbErr != nil { 132 - return dbErr 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 133 60 } 134 - } else { 135 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 61 + 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 136 63 if dbErr != nil { 137 64 return dbErr 138 65 } 66 + return err 139 67 } 68 + defer eng.DestroyWorkflow(ctx, wid) 140 69 141 - return fmt.Errorf("starting steps image: %w", err) 142 - } 70 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 + if err != nil { 72 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 + wfLogger = nil 74 + } else { 75 + defer wfLogger.Close() 76 + } 143 77 144 - err = e.db.StatusSuccess(wid, e.n) 145 - if err != nil { 146 - return err 147 - } 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 148 80 149 - return nil 150 - }) 151 - } 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 152 86 153 - if err = eg.Wait(); err != nil { 154 - e.l.Error("failed to run one or more workflows", "err", err) 155 - } else { 156 - e.l.Error("successfully ran full pipeline") 157 - } 158 - } 87 + err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 + if err != nil { 89 + if errors.Is(err, ErrTimedOut) { 90 + dbErr := db.StatusTimeout(wid, n) 91 + if dbErr != nil { 92 + return dbErr 93 + } 94 + } else { 95 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 + if dbErr != nil { 97 + return dbErr 98 + } 99 + } 159 100 160 - // SetupWorkflow sets up a new network for the workflow and volumes for 161 - // the workspace and Nix store. These are persisted across steps and are 162 - // destroyed at the end of the workflow. 163 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 164 - e.l.Info("setting up workflow", "workflow", wid) 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 165 104 166 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 167 - Name: workspaceVolume(wid), 168 - Driver: "local", 169 - }) 170 - if err != nil { 171 - return err 172 - } 173 - e.registerCleanup(wid, func(ctx context.Context) error { 174 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 175 - }) 176 - 177 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 178 - Name: nixVolume(wid), 179 - Driver: "local", 180 - }) 181 - if err != nil { 182 - return err 183 - } 184 - e.registerCleanup(wid, func(ctx context.Context) error { 185 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 186 - }) 187 - 188 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 189 - Driver: "bridge", 190 - }) 191 - if err != nil { 192 - return err 193 - } 194 - e.registerCleanup(wid, func(ctx context.Context) error { 195 - return e.docker.NetworkRemove(ctx, networkName(wid)) 196 - }) 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 197 109 198 - return nil 199 - } 200 - 201 - // StartSteps starts all steps sequentially with the same base image. 202 - // ONLY marks pipeline as failed if container's exit code is non-zero. 203 - // All other errors are bubbled up. 204 - // Fixed version of the step execution logic 205 - func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 206 - workflowEnvs := ConstructEnvs(w.Environment) 207 - for _, s := range secrets { 208 - workflowEnvs.AddEnv(s.Key, s.Value) 209 - } 210 - 211 - for stepIdx, step := range w.Steps { 212 - select { 213 - case <-ctx.Done(): 214 - return ctx.Err() 215 - default: 216 - } 217 - 218 - envs := append(EnvVars(nil), workflowEnvs...) 219 - for k, v := range step.Environment { 220 - envs.AddEnv(k, v) 221 - } 222 - envs.AddEnv("HOME", workspaceDir) 223 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 224 - 225 - hostConfig := hostConfig(wid) 226 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 227 - Image: w.Image, 228 - Cmd: []string{"bash", "-c", step.Command}, 229 - WorkingDir: workspaceDir, 230 - Tty: false, 231 - Hostname: "spindle", 232 - Env: envs.Slice(), 233 - }, hostConfig, nil, nil, "") 234 - defer e.DestroyStep(ctx, resp.ID) 235 - if err != nil { 236 - return fmt.Errorf("creating container: %w", err) 237 - } 238 - 239 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 240 - if err != nil { 241 - return fmt.Errorf("connecting network: %w", err) 242 - } 243 - 244 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 - if err != nil { 246 - return err 247 - } 248 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 249 - 250 - // start tailing logs in background 251 - tailDone := make(chan error, 1) 252 - go func() { 253 - tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step) 254 - }() 255 - 256 - // wait for container completion or timeout 257 - waitDone := make(chan struct{}) 258 - var state *container.State 259 - var waitErr error 260 - 261 - go func() { 262 - defer close(waitDone) 263 - state, waitErr = e.WaitStep(ctx, resp.ID) 264 - }() 265 - 266 - select { 267 - case <-waitDone: 268 - 269 - // wait for tailing to complete 270 - <-tailDone 271 - 272 - case <-ctx.Done(): 273 - e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name) 274 - err = e.DestroyStep(context.Background(), resp.ID) 275 - if err != nil { 276 - e.l.Error("failed to destroy step", "container", resp.ID, "error", err) 277 - } 278 - 279 - // wait for both goroutines to finish 280 - <-waitDone 281 - <-tailDone 282 - 283 - return ErrTimedOut 284 - } 285 - 286 - select { 287 - case <-ctx.Done(): 288 - return ctx.Err() 289 - default: 290 - } 291 - 292 - if waitErr != nil { 293 - return waitErr 294 - } 295 - 296 - err = e.DestroyStep(ctx, resp.ID) 297 - if err != nil { 298 - return err 299 - } 300 - 301 - if state.ExitCode != 0 { 302 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 303 - if state.OOMKilled { 304 - return ErrOOMKilled 305 - } 306 - return ErrWorkflowFailed 110 + return nil 111 + }) 307 112 } 308 113 } 309 114 310 - return nil 311 - } 312 - 313 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 314 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 315 - select { 316 - case err := <-errCh: 317 - if err != nil { 318 - return nil, err 319 - } 320 - case <-wait: 321 - } 322 - 323 - e.l.Info("waited for container", "name", containerID) 324 - 325 - info, err := e.docker.ContainerInspect(ctx, containerID) 326 - if err != nil { 327 - return nil, err 328 - } 329 - 330 - return info.State, nil 331 - } 332 - 333 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 334 - wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid) 335 - if err != nil { 336 - e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 337 - return err 115 + if err := eg.Wait(); err != nil { 116 + l.Error("failed to run one or more workflows", "err", err) 117 + } else { 118 + l.Error("successfully ran full pipeline") 338 119 } 339 - defer wfLogger.Close() 340 - 341 - ctl := wfLogger.ControlWriter(stepIdx, step) 342 - ctl.Write([]byte(step.Name)) 343 - 344 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 345 - Follow: true, 346 - ShowStdout: true, 347 - ShowStderr: true, 348 - Details: false, 349 - Timestamps: false, 350 - }) 351 - if err != nil { 352 - return err 353 - } 354 - 355 - _, err = stdcopy.StdCopy( 356 - wfLogger.DataWriter("stdout"), 357 - wfLogger.DataWriter("stderr"), 358 - logs, 359 - ) 360 - if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 361 - return fmt.Errorf("failed to copy logs: %w", err) 362 - } 363 - 364 - return nil 365 - } 366 - 367 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 368 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 369 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 370 - return err 371 - } 372 - 373 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 374 - RemoveVolumes: true, 375 - RemoveLinks: false, 376 - Force: false, 377 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 378 - return err 379 - } 380 - 381 - return nil 382 - } 383 - 384 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 385 - e.cleanupMu.Lock() 386 - key := wid.String() 387 - 388 - fns := e.cleanup[key] 389 - delete(e.cleanup, key) 390 - e.cleanupMu.Unlock() 391 - 392 - for _, fn := range fns { 393 - if err := fn(ctx); err != nil { 394 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 395 - } 396 - } 397 - return nil 398 - } 399 - 400 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 401 - e.cleanupMu.Lock() 402 - defer e.cleanupMu.Unlock() 403 - 404 - key := wid.String() 405 - e.cleanup[key] = append(e.cleanup[key], fn) 406 - } 407 - 408 - func workspaceVolume(wid models.WorkflowId) string { 409 - return fmt.Sprintf("workspace-%s", wid) 410 - } 411 - 412 - func nixVolume(wid models.WorkflowId) string { 413 - return fmt.Sprintf("nix-%s", wid) 414 - } 415 - 416 - func networkName(wid models.WorkflowId) string { 417 - return fmt.Sprintf("workflow-network-%s", wid) 418 - } 419 - 420 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 421 - hostConfig := &container.HostConfig{ 422 - Mounts: []mount.Mount{ 423 - { 424 - Type: mount.TypeVolume, 425 - Source: workspaceVolume(wid), 426 - Target: workspaceDir, 427 - }, 428 - { 429 - Type: mount.TypeVolume, 430 - Source: nixVolume(wid), 431 - Target: "/nix", 432 - }, 433 - { 434 - Type: mount.TypeTmpfs, 435 - Target: "/tmp", 436 - ReadOnly: false, 437 - TmpfsOptions: &mount.TmpfsOptions{ 438 - Mode: 0o1777, // world-writeable sticky bit 439 - Options: [][]string{ 440 - {"exec"}, 441 - }, 442 - }, 443 - }, 444 - { 445 - Type: mount.TypeVolume, 446 - Source: "etc-nix-" + wid.String(), 447 - Target: "/etc/nix", 448 - }, 449 - }, 450 - ReadonlyRootfs: false, 451 - CapDrop: []string{"ALL"}, 452 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 453 - SecurityOpt: []string{"no-new-privileges"}, 454 - ExtraHosts: []string{"host.docker.internal:host-gateway"}, 455 - } 456 - 457 - return hostConfig 458 - } 459 - 460 - // thanks woodpecker 461 - func isErrContainerNotFoundOrNotRunning(err error) bool { 462 - // Error response from daemon: Cannot kill container: ...: No such container: ... 463 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 464 - // Error response from podman daemon: can only kill running containers. ... is in state exited 465 - // Error: No such container: ... 466 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 467 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.ElementsMatch(t, tt.want, got) 38 - }) 39 - } 40 - } 41 - 42 - func TestAddEnv(t *testing.T) { 43 - ev := EnvVars{} 44 - ev.AddEnv("FOO", "bar") 45 - ev.AddEnv("BAZ", "qux") 46 - want := EnvVars{"FOO=bar", "BAZ=qux"} 47 - assert.ElementsMatch(t, want, ev) 48 - }
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
-84
spindle/engine/logger.go
··· 1 - package engine 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "path/filepath" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 - ) 13 - 14 - type WorkflowLogger struct { 15 - file *os.File 16 - encoder *json.Encoder 17 - } 18 - 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - path := LogFilePath(baseDir, wid) 21 - 22 - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 23 - if err != nil { 24 - return nil, fmt.Errorf("creating log file: %w", err) 25 - } 26 - 27 - return &WorkflowLogger{ 28 - file: file, 29 - encoder: json.NewEncoder(file), 30 - }, nil 31 - } 32 - 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 - logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 - return logFilePath 36 - } 37 - 38 - func (l *WorkflowLogger) Close() error { 39 - return l.file.Close() 40 - } 41 - 42 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 - // TODO: emit stream 44 - return &dataWriter{ 45 - logger: l, 46 - stream: stream, 47 - } 48 - } 49 - 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 - return &controlWriter{ 52 - logger: l, 53 - idx: idx, 54 - step: step, 55 - } 56 - } 57 - 58 - type dataWriter struct { 59 - logger *WorkflowLogger 60 - stream string 61 - } 62 - 63 - func (w *dataWriter) Write(p []byte) (int, error) { 64 - line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 - if err := w.logger.encoder.Encode(entry); err != nil { 67 - return 0, err 68 - } 69 - return len(p), nil 70 - } 71 - 72 - type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step models.Step 76 - } 77 - 78 - func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 - if err := w.logger.encoder.Encode(entry); err != nil { 81 - return 0, err 82 - } 83 - return len(w.step.Name), nil 84 - }
+21
spindle/engines/nixery/ansi_stripper.go
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+421
spindle/engines/nixery/engine.go
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + Labels: map[string]string{ 205 + "sh.tangled.pipeline/workflow_id": wid.String(), 206 + }, 207 + // TODO(winter): investigate whether environment variables passed here 208 + // get propagated to ContainerExec processes 209 + }, &container.HostConfig{ 210 + Mounts: []mount.Mount{ 211 + { 212 + Type: mount.TypeTmpfs, 213 + Target: "/tmp", 214 + ReadOnly: false, 215 + TmpfsOptions: &mount.TmpfsOptions{ 216 + Mode: 0o1777, // world-writeable sticky bit 217 + Options: [][]string{ 218 + {"exec"}, 219 + }, 220 + }, 221 + }, 222 + }, 223 + ReadonlyRootfs: false, 224 + CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 + SecurityOpt: []string{"no-new-privileges"}, 227 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 + }, nil, nil, "") 229 + if err != nil { 230 + return fmt.Errorf("creating container: %w", err) 231 + } 232 + e.registerCleanup(wid, func(ctx context.Context) error { 233 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 + RemoveVolumes: true, 240 + RemoveLinks: false, 241 + Force: false, 242 + }) 243 + }) 244 + 245 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 246 + if err != nil { 247 + return fmt.Errorf("starting container: %w", err) 248 + } 249 + 250 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 251 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 252 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 253 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 254 + }) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + // This actually *starts* the command. Thanks, Docker! 260 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 261 + if err != nil { 262 + return err 263 + } 264 + defer execResp.Close() 265 + 266 + // This is apparently best way to wait for the command to complete. 267 + _, err = io.ReadAll(execResp.Reader) 268 + if err != nil { 269 + return err 270 + } 271 + 272 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + if execInspectResp.ExitCode != 0 { 278 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 279 + } else if execInspectResp.Running { 280 + return errors.New("mkdir is somehow still running??") 281 + } 282 + 283 + addl.container = resp.ID 284 + wf.Data = addl 285 + 286 + return nil 287 + } 288 + 289 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 + addl := w.Data.(addlFields) 291 + workflowEnvs := ConstructEnvs(addl.env) 292 + // TODO(winter): should SetupWorkflow also have secret access? 293 + // IMO yes, but probably worth thinking on. 294 + for _, s := range secrets { 295 + workflowEnvs.AddEnv(s.Key, s.Value) 296 + } 297 + 298 + step := w.Steps[idx].(Step) 299 + 300 + select { 301 + case <-ctx.Done(): 302 + return ctx.Err() 303 + default: 304 + } 305 + 306 + envs := append(EnvVars(nil), workflowEnvs...) 307 + for k, v := range step.environment { 308 + envs.AddEnv(k, v) 309 + } 310 + envs.AddEnv("HOME", homeDir) 311 + 312 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 + Cmd: []string{"bash", "-c", step.command}, 314 + AttachStdout: true, 315 + AttachStderr: true, 316 + Env: envs, 317 + }) 318 + if err != nil { 319 + return fmt.Errorf("creating exec: %w", err) 320 + } 321 + 322 + // start tailing logs in background 323 + tailDone := make(chan error, 1) 324 + go func() { 325 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 326 + }() 327 + 328 + select { 329 + case <-tailDone: 330 + 331 + case <-ctx.Done(): 332 + // cleanup will be handled by DestroyWorkflow, since 333 + // Docker doesn't provide an API to kill an exec run 334 + // (sure, we could grab the PID and kill it ourselves, 335 + // but that's wasted effort) 336 + e.l.Warn("step timed out", "step", step.Name) 337 + 338 + <-tailDone 339 + 340 + return engine.ErrTimedOut 341 + } 342 + 343 + select { 344 + case <-ctx.Done(): 345 + return ctx.Err() 346 + default: 347 + } 348 + 349 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + if execInspectResp.ExitCode != 0 { 355 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 356 + if err != nil { 357 + return err 358 + } 359 + 360 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 361 + 362 + if inspectResp.State.OOMKilled { 363 + return ErrOOMKilled 364 + } 365 + return engine.ErrWorkflowFailed 366 + } 367 + 368 + return nil 369 + } 370 + 371 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 372 + if wfLogger == nil { 373 + return nil 374 + } 375 + 376 + // This actually *starts* the command. Thanks, Docker! 377 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 378 + if err != nil { 379 + return err 380 + } 381 + defer logs.Close() 382 + 383 + _, err = stdcopy.StdCopy( 384 + wfLogger.DataWriter("stdout"), 385 + wfLogger.DataWriter("stderr"), 386 + logs.Reader, 387 + ) 388 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 389 + return fmt.Errorf("failed to copy logs: %w", err) 390 + } 391 + 392 + return nil 393 + } 394 + 395 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 396 + e.cleanupMu.Lock() 397 + key := wid.String() 398 + 399 + fns := e.cleanup[key] 400 + delete(e.cleanup, key) 401 + e.cleanupMu.Unlock() 402 + 403 + for _, fn := range fns { 404 + if err := fn(ctx); err != nil { 405 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 406 + } 407 + } 408 + return nil 409 + } 410 + 411 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 412 + e.cleanupMu.Lock() 413 + defer e.cleanupMu.Unlock() 414 + 415 + key := wid.String() 416 + e.cleanup[key] = append(e.cleanup[key], fn) 417 + } 418 + 419 + func networkName(wid models.WorkflowId) string { 420 + return fmt.Sprintf("workflow-network-%s", wid) 421 + }
+28
spindle/engines/nixery/envs.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+8 -4
spindle/ingester.go
··· 40 40 41 41 switch e.Commit.Collection { 42 42 case tangled.SpindleMemberNSID: 43 - s.ingestMember(ctx, e) 43 + err = s.ingestMember(ctx, e) 44 44 case tangled.RepoNSID: 45 - s.ingestRepo(ctx, e) 45 + err = s.ingestRepo(ctx, e) 46 46 case tangled.RepoCollaboratorNSID: 47 - s.ingestCollaborator(ctx, e) 47 + err = s.ingestCollaborator(ctx, e) 48 48 } 49 49 50 - return err 50 + if err != nil { 51 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 52 + } 53 + 54 + return nil 51 55 } 52 56 } 53 57
+17
spindle/models/engine.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/spindle/secrets" 9 + ) 10 + 11 + type Engine interface { 12 + InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 + WorkflowTimeout() time.Duration 15 + DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 + }
+82
spindle/models/logger.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + type WorkflowLogger struct { 13 + file *os.File 14 + encoder *json.Encoder 15 + } 16 + 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + path := LogFilePath(baseDir, wid) 19 + 20 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 21 + if err != nil { 22 + return nil, fmt.Errorf("creating log file: %w", err) 23 + } 24 + 25 + return &WorkflowLogger{ 26 + file: file, 27 + encoder: json.NewEncoder(file), 28 + }, nil 29 + } 30 + 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 + logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 + return logFilePath 34 + } 35 + 36 + func (l *WorkflowLogger) Close() error { 37 + return l.file.Close() 38 + } 39 + 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 42 + return &dataWriter{ 43 + logger: l, 44 + stream: stream, 45 + } 46 + } 47 + 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 + return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + } 54 + } 55 + 56 + type dataWriter struct { 57 + logger *WorkflowLogger 58 + stream string 59 + } 60 + 61 + func (w *dataWriter) Write(p []byte) (int, error) { 62 + line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 + if err := w.logger.encoder.Encode(entry); err != nil { 65 + return 0, err 66 + } 67 + return len(p), nil 68 + } 69 + 70 + type controlWriter struct { 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 74 + } 75 + 76 + func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 + if err := w.logger.encoder.Encode(entry); err != nil { 79 + return 0, err 80 + } 81 + return len(w.step.Name()), nil 82 + }
+3 -3
spindle/models/models.go
··· 104 104 func NewControlLogLine(idx int, step Step) LogLine { 105 105 return LogLine{ 106 106 Kind: LogKindControl, 107 - Content: step.Name, 107 + Content: step.Name(), 108 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 111 } 112 112 }
+8 -103
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 4 RepoOwner string 12 5 RepoName string 13 - Workflows []Workflow 6 + Workflows map[Engine][]Workflow 14 7 } 15 8 16 - type Step struct { 17 - Command string 18 - Name string 19 - Environment map[string]string 20 - Kind StepKind 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 21 13 } 22 14 23 15 type StepKind int ··· 30 22 ) 31 23 32 24 type Workflow struct { 33 - Steps []Step 34 - Environment map[string]string 35 - Name string 36 - Image string 37 - } 38 - 39 - // setupSteps get added to start of Steps 40 - type setupSteps []Step 41 - 42 - // addStep adds a step to the beginning of the workflow's steps. 43 - func (ss *setupSteps) addStep(step Step) { 44 - *ss = append(*ss, step) 45 - } 46 - 47 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 48 - // In the process, dependencies are resolved: nixpkgs deps 49 - // are constructed atop nixery and set as the Workflow.Image, 50 - // and ones from custom registries 51 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 52 - workflows := []Workflow{} 53 - 54 - for _, twf := range pl.Workflows { 55 - swf := &Workflow{} 56 - for _, tstep := range twf.Steps { 57 - sstep := Step{} 58 - sstep.Environment = stepEnvToMap(tstep.Environment) 59 - sstep.Command = tstep.Command 60 - sstep.Name = tstep.Name 61 - sstep.Kind = StepKindUser 62 - swf.Steps = append(swf.Steps, sstep) 63 - } 64 - swf.Name = twf.Name 65 - swf.Environment = workflowEnvToMap(twf.Environment) 66 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 - 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - repoOwner := pl.TriggerMetadata.Repo.Did 83 - repoName := pl.TriggerMetadata.Repo.Repo 84 - return &Pipeline{ 85 - RepoOwner: repoOwner, 86 - RepoName: repoName, 87 - Workflows: workflows, 88 - } 89 - } 90 - 91 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 92 - envMap := map[string]string{} 93 - for _, env := range envs { 94 - if env != nil { 95 - envMap[env.Key] = env.Value 96 - } 97 - } 98 - return envMap 99 - } 100 - 101 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 102 - envMap := map[string]string{} 103 - for _, env := range envs { 104 - if env != nil { 105 - envMap[env.Key] = env.Value 106 - } 107 - } 108 - return envMap 109 - } 110 - 111 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 112 - var dependencies string 113 - for _, d := range deps { 114 - if d.Registry == "nixpkgs" { 115 - dependencies = path.Join(d.Packages...) 116 - } 117 - } 118 - 119 - // load defaults from somewhere else 120 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 121 - 122 - return path.Join(nixery, dependencies) 25 + Steps []Step 26 + Name string 27 + Data any 123 28 }
-128
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 - ) 11 - 12 - func nixConfStep() Step { 13 - setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 14 - echo 'build-users-group = ' >> /etc/nix/nix.conf` 15 - return Step{ 16 - Command: setupCmd, 17 - Name: "Configure Nix", 18 - } 19 - } 20 - 21 - // cloneOptsAsSteps processes clone options and adds corresponding steps 22 - // to the beginning of the workflow's step list if cloning is not skipped. 23 - // 24 - // the steps to do here are: 25 - // - git init 26 - // - git remote add origin <url> 27 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 28 - // - git checkout FETCH_HEAD 29 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 30 - if twf.Clone.Skip { 31 - return Step{} 32 - } 33 - 34 - var commands []string 35 - 36 - // initialize git repo in workspace 37 - commands = append(commands, "git init") 38 - 39 - // add repo as git remote 40 - scheme := "https://" 41 - if dev { 42 - scheme = "http://" 43 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 44 - } 45 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 46 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 47 - 48 - // run git fetch 49 - { 50 - var fetchArgs []string 51 - 52 - // default clone depth is 1 53 - depth := 1 54 - if twf.Clone.Depth > 1 { 55 - depth = int(twf.Clone.Depth) 56 - } 57 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 58 - 59 - // optionally recurse submodules 60 - if twf.Clone.Submodules { 61 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 62 - } 63 - 64 - // set remote to fetch from 65 - fetchArgs = append(fetchArgs, "origin") 66 - 67 - // set revision to checkout 68 - switch workflow.TriggerKind(tr.Kind) { 69 - case workflow.TriggerKindManual: 70 - // TODO: unimplemented 71 - case workflow.TriggerKindPush: 72 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 73 - case workflow.TriggerKindPullRequest: 74 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 75 - } 76 - 77 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 78 - } 79 - 80 - // run git checkout 81 - commands = append(commands, "git checkout FETCH_HEAD") 82 - 83 - cloneStep := Step{ 84 - Command: strings.Join(commands, "\n"), 85 - Name: "Clone repository into workspace", 86 - } 87 - return cloneStep 88 - } 89 - 90 - // dependencyStep processes dependencies defined in the workflow. 91 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 92 - // all packages and adds a single 'nix profile install' step to the 93 - // beginning of the workflow's step list. 94 - func dependencyStep(twf tangled.Pipeline_Workflow) *Step { 95 - var customPackages []string 96 - 97 - for _, d := range twf.Dependencies { 98 - registry := d.Registry 99 - packages := d.Packages 100 - 101 - if registry == "nixpkgs" { 102 - continue 103 - } 104 - 105 - if len(packages) == 0 { 106 - customPackages = append(customPackages, registry) 107 - } 108 - // collect packages from custom registries 109 - for _, pkg := range packages { 110 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 111 - } 112 - } 113 - 114 - if len(customPackages) > 0 { 115 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 116 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 117 - installStep := Step{ 118 - Command: cmd, 119 - Name: "Install custom dependencies", 120 - Environment: map[string]string{ 121 - "NIX_NO_COLOR": "1", 122 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 123 - }, 124 - } 125 - return &installStep 126 - } 127 - return nil 128 - }
+1 -1
spindle/secrets/sqlite.go
··· 24 24 } 25 25 26 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 - db, err := sql.Open("sqlite3", dbPath) 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 28 28 if err != nil { 29 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 30 }
+54 -15
spindle/server.go
··· 20 20 "tangled.sh/tangled.sh/core/spindle/config" 21 21 "tangled.sh/tangled.sh/core/spindle/db" 22 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 + "tangled.sh/tangled.sh/core/spindle/engines/nixery" 23 24 "tangled.sh/tangled.sh/core/spindle/models" 24 25 "tangled.sh/tangled.sh/core/spindle/queue" 25 26 "tangled.sh/tangled.sh/core/spindle/secrets" 26 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 27 29 ) 28 30 29 31 //go:embed motd ··· 39 41 e *rbac.Enforcer 40 42 l *slog.Logger 41 43 n *notifier.Notifier 42 - eng *engine.Engine 44 + engs map[string]models.Engine 43 45 jq *queue.Queue 44 46 cfg *config.Config 45 47 ks *eventconsumer.Consumer ··· 93 95 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 96 } 95 97 96 - eng, err := engine.New(ctx, cfg, d, &n, vault) 98 + nixeryEng, err := nixery.New(ctx, cfg) 97 99 if err != nil { 98 100 return err 99 101 } 100 102 101 - jq := queue.NewQueue(100, 5) 103 + jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 + logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 102 105 103 106 collections := []string{ 104 107 tangled.SpindleMemberNSID, ··· 128 131 db: d, 129 132 l: logger, 130 133 n: &n, 131 - eng: eng, 134 + engs: map[string]models.Engine{"nixery": nixeryEng}, 132 135 jq: jq, 133 136 cfg: cfg, 134 137 res: resolver, ··· 212 215 func (s *Spindle) XrpcRouter() http.Handler { 213 216 logger := s.l.With("route", "xrpc") 214 217 218 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 219 + 215 220 x := xrpc.Xrpc{ 216 - Logger: logger, 217 - Db: s.db, 218 - Enforcer: s.e, 219 - Engine: s.eng, 220 - Config: s.cfg, 221 - Resolver: s.res, 222 - Vault: s.vault, 221 + Logger: logger, 222 + Db: s.db, 223 + Enforcer: s.e, 224 + Engines: s.engs, 225 + Config: s.cfg, 226 + Resolver: s.res, 227 + Vault: s.vault, 228 + ServiceAuth: serviceAuth, 223 229 } 224 230 225 231 return x.Router() ··· 242 248 return fmt.Errorf("no repo data found") 243 249 } 244 250 251 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 252 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 253 + } 254 + 245 255 // filter by repos 246 256 _, err = s.db.GetRepo( 247 257 tpl.TriggerMetadata.Repo.Knot, ··· 257 267 Rkey: msg.Rkey, 258 268 } 259 269 270 + workflows := make(map[models.Engine][]models.Workflow) 271 + 260 272 for _, w := range tpl.Workflows { 261 273 if w != nil { 262 - err := s.db.StatusPending(models.WorkflowId{ 274 + if _, ok := s.engs[w.Engine]; !ok { 275 + err = s.db.StatusFailed(models.WorkflowId{ 276 + PipelineId: pipelineId, 277 + Name: w.Name, 278 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 279 + if err != nil { 280 + return err 281 + } 282 + 283 + continue 284 + } 285 + 286 + eng := s.engs[w.Engine] 287 + 288 + if _, ok := workflows[eng]; !ok { 289 + workflows[eng] = []models.Workflow{} 290 + } 291 + 292 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 293 + if err != nil { 294 + return err 295 + } 296 + 297 + workflows[eng] = append(workflows[eng], *ewf) 298 + 299 + err = s.db.StatusPending(models.WorkflowId{ 263 300 PipelineId: pipelineId, 264 301 Name: w.Name, 265 302 }, s.n) ··· 269 306 } 270 307 } 271 308 272 - spl := models.ToPipeline(tpl, *s.cfg) 273 - 274 309 ok := s.jq.Enqueue(queue.Job{ 275 310 Run: func() error { 276 - s.eng.StartWorkflows(ctx, spl, pipelineId) 311 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 312 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 313 + RepoName: tpl.TriggerMetadata.Repo.Repo, 314 + Workflows: workflows, 315 + }, pipelineId) 277 316 return nil 278 317 }, 279 318 OnFail: func(jobError error) {
+32 -2
spindle/stream.go
··· 6 6 "fmt" 7 7 "io" 8 8 "net/http" 9 + "os" 9 10 "strconv" 10 11 "time" 11 12 12 - "tangled.sh/tangled.sh/core/spindle/engine" 13 13 "tangled.sh/tangled.sh/core/spindle/models" 14 14 15 15 "github.com/go-chi/chi/v5" ··· 143 143 } 144 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 145 146 - filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid) 146 + filePath := models.LogFilePath(s.cfg.Server.LogDir, wid) 147 + 148 + if status.Status == models.StatusKindFailed.String() && status.Error != nil { 149 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 150 + msgs := []models.LogLine{ 151 + { 152 + Kind: models.LogKindControl, 153 + Content: "", 154 + StepId: 0, 155 + StepKind: models.StepKindUser, 156 + }, 157 + { 158 + Kind: models.LogKindData, 159 + Content: *status.Error, 160 + }, 161 + } 162 + 163 + for _, msg := range msgs { 164 + b, err := json.Marshal(msg) 165 + if err != nil { 166 + return err 167 + } 168 + 169 + if err := conn.WriteMessage(websocket.TextMessage, b); err != nil { 170 + return fmt.Errorf("failed to write to websocket: %w", err) 171 + } 172 + } 173 + 174 + return nil 175 + } 176 + } 147 177 148 178 config := tail.Config{ 149 179 Follow: !isFinished,
+11 -10
spindle/xrpc/add_secret.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 17 ) 17 18 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 20 l := x.Logger 20 - fail := func(e XrpcError) { 21 + fail := func(e xrpcerr.XrpcError) { 21 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 23 writeError(w, e, http.StatusBadRequest) 23 24 } 24 25 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 27 if !ok { 27 - fail(MissingActorDidError) 28 + fail(xrpcerr.MissingActorDidError) 28 29 return 29 30 } 30 31 31 32 var data tangled.RepoAddSecret_Input 32 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(GenericError(err)) 34 + fail(xrpcerr.GenericError(err)) 34 35 return 35 36 } 36 37 37 38 if err := secrets.ValidateKey(data.Key); err != nil { 38 - fail(GenericError(err)) 39 + fail(xrpcerr.GenericError(err)) 39 40 return 40 41 } 41 42 42 43 // unfortunately we have to resolve repo-at here 43 44 repoAt, err := syntax.ParseATURI(data.Repo) 44 45 if err != nil { 45 - fail(InvalidRepoError(data.Repo)) 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 46 47 return 47 48 } 48 49 49 50 // resolve this aturi to extract the repo record 50 51 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 52 if err != nil || ident.Handle.IsInvalidHandle() { 52 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 54 return 54 55 } 55 56 56 57 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 58 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 59 if err != nil { 59 - fail(GenericError(err)) 60 + fail(xrpcerr.GenericError(err)) 60 61 return 61 62 } 62 63 63 64 repo := resp.Value.Val.(*tangled.Repo) 64 65 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 66 if err != nil { 66 - fail(GenericError(err)) 67 + fail(xrpcerr.GenericError(err)) 67 68 return 68 69 } 69 70 70 71 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 72 l.Error("insufficent permissions", "did", actorDid.String()) 72 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 74 return 74 75 } 75 76 ··· 83 84 err = x.Vault.AddSecret(r.Context(), secret) 84 85 if err != nil { 85 86 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 - writeError(w, GenericError(err), http.StatusInternalServerError) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 87 88 return 88 89 } 89 90
+10 -9
spindle/xrpc/list_secrets.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 17 ) 17 18 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 20 l := x.Logger 20 - fail := func(e XrpcError) { 21 + fail := func(e xrpcerr.XrpcError) { 21 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 23 writeError(w, e, http.StatusBadRequest) 23 24 } 24 25 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 27 if !ok { 27 - fail(MissingActorDidError) 28 + fail(xrpcerr.MissingActorDidError) 28 29 return 29 30 } 30 31 31 32 repoParam := r.URL.Query().Get("repo") 32 33 if repoParam == "" { 33 - fail(GenericError(fmt.Errorf("empty params"))) 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 34 35 return 35 36 } 36 37 37 38 // unfortunately we have to resolve repo-at here 38 39 repoAt, err := syntax.ParseATURI(repoParam) 39 40 if err != nil { 40 - fail(InvalidRepoError(repoParam)) 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 41 42 return 42 43 } 43 44 44 45 // resolve this aturi to extract the repo record 45 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 47 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 49 return 49 50 } 50 51 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 53 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 54 if err != nil { 54 - fail(GenericError(err)) 55 + fail(xrpcerr.GenericError(err)) 55 56 return 56 57 } 57 58 58 59 repo := resp.Value.Val.(*tangled.Repo) 59 60 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 61 if err != nil { 61 - fail(GenericError(err)) 62 + fail(xrpcerr.GenericError(err)) 62 63 return 63 64 } 64 65 65 66 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 67 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 69 return 69 70 } 70 71 71 72 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 73 if err != nil { 73 74 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 - writeError(w, GenericError(err), http.StatusInternalServerError) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 75 76 return 76 77 } 77 78
+10 -9
spindle/xrpc/remove_secret.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/rbac" 14 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 16 ) 16 17 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 19 l := x.Logger 19 - fail := func(e XrpcError) { 20 + fail := func(e xrpcerr.XrpcError) { 20 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 21 22 writeError(w, e, http.StatusBadRequest) 22 23 } 23 24 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 26 if !ok { 26 - fail(MissingActorDidError) 27 + fail(xrpcerr.MissingActorDidError) 27 28 return 28 29 } 29 30 30 31 var data tangled.RepoRemoveSecret_Input 31 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 - fail(GenericError(err)) 33 + fail(xrpcerr.GenericError(err)) 33 34 return 34 35 } 35 36 36 37 // unfortunately we have to resolve repo-at here 37 38 repoAt, err := syntax.ParseATURI(data.Repo) 38 39 if err != nil { 39 - fail(InvalidRepoError(data.Repo)) 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 40 41 return 41 42 } 42 43 43 44 // resolve this aturi to extract the repo record 44 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 46 if err != nil || ident.Handle.IsInvalidHandle() { 46 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 48 return 48 49 } 49 50 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 53 if err != nil { 53 - fail(GenericError(err)) 54 + fail(xrpcerr.GenericError(err)) 54 55 return 55 56 } 56 57 57 58 repo := resp.Value.Val.(*tangled.Repo) 58 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 60 if err != nil { 60 - fail(GenericError(err)) 61 + fail(xrpcerr.GenericError(err)) 61 62 return 62 63 } 63 64 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 66 l.Error("insufficent permissions", "did", actorDid.String()) 66 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 68 return 68 69 } 69 70 ··· 74 75 err = x.Vault.RemoveSecret(r.Context(), secret) 75 76 if err != nil { 76 77 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 - writeError(w, GenericError(err), http.StatusInternalServerError) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 78 79 return 79 80 } 80 81
+15 -110
spindle/xrpc/xrpc.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 - "context" 5 4 _ "embed" 6 5 "encoding/json" 7 - "fmt" 8 6 "log/slog" 9 7 "net/http" 10 - "strings" 11 8 12 - "github.com/bluesky-social/indigo/atproto/auth" 13 9 "github.com/go-chi/chi/v5" 14 10 15 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 17 13 "tangled.sh/tangled.sh/core/rbac" 18 14 "tangled.sh/tangled.sh/core/spindle/config" 19 15 "tangled.sh/tangled.sh/core/spindle/db" 20 - "tangled.sh/tangled.sh/core/spindle/engine" 16 + "tangled.sh/tangled.sh/core/spindle/models" 21 17 "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 22 20 ) 23 21 24 22 const ActorDid string = "ActorDid" 25 23 26 24 type Xrpc struct { 27 - Logger *slog.Logger 28 - Db *db.DB 29 - Enforcer *rbac.Enforcer 30 - Engine *engine.Engine 31 - Config *config.Config 32 - Resolver *idresolver.Resolver 33 - Vault secrets.Manager 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 34 33 } 35 34 36 35 func (x *Xrpc) Router() http.Handler { 37 36 r := chi.NewRouter() 38 37 39 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 - r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 38 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 + r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 41 43 42 return r 44 43 } 45 44 46 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 - l := x.Logger.With("url", r.URL) 49 - 50 - token := r.Header.Get("Authorization") 51 - token = strings.TrimPrefix(token, "Bearer ") 52 - 53 - s := auth.ServiceAuthValidator{ 54 - Audience: x.Config.Server.Did().String(), 55 - Dir: x.Resolver.Directory(), 56 - } 57 - 58 - did, err := s.Validate(r.Context(), token, nil) 59 - if err != nil { 60 - l.Error("signature verification failed", "err", err) 61 - writeError(w, AuthError(err), http.StatusForbidden) 62 - return 63 - } 64 - 65 - r = r.WithContext( 66 - context.WithValue(r.Context(), ActorDid, did), 67 - ) 68 - 69 - next.ServeHTTP(w, r) 70 - }) 71 - } 72 - 73 - type XrpcError struct { 74 - Tag string `json:"error"` 75 - Message string `json:"message"` 76 - } 77 - 78 - func NewXrpcError(opts ...ErrOpt) XrpcError { 79 - x := XrpcError{} 80 - for _, o := range opts { 81 - o(&x) 82 - } 83 - 84 - return x 85 - } 86 - 87 - type ErrOpt = func(xerr *XrpcError) 88 - 89 - func WithTag(tag string) ErrOpt { 90 - return func(xerr *XrpcError) { 91 - xerr.Tag = tag 92 - } 93 - } 94 - 95 - func WithMessage[S ~string](s S) ErrOpt { 96 - return func(xerr *XrpcError) { 97 - xerr.Message = string(s) 98 - } 99 - } 100 - 101 - func WithError(e error) ErrOpt { 102 - return func(xerr *XrpcError) { 103 - xerr.Message = e.Error() 104 - } 105 - } 106 - 107 - var MissingActorDidError = NewXrpcError( 108 - WithTag("MissingActorDid"), 109 - WithMessage("actor DID not supplied"), 110 - ) 111 - 112 - var AuthError = func(err error) XrpcError { 113 - return NewXrpcError( 114 - WithTag("Auth"), 115 - WithError(fmt.Errorf("signature verification failed: %w", err)), 116 - ) 117 - } 118 - 119 - var InvalidRepoError = func(r string) XrpcError { 120 - return NewXrpcError( 121 - WithTag("InvalidRepo"), 122 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 - ) 124 - } 125 - 126 - func GenericError(err error) XrpcError { 127 - return NewXrpcError( 128 - WithTag("Generic"), 129 - WithError(err), 130 - ) 131 - } 132 - 133 - var AccessControlError = func(d string) XrpcError { 134 - return NewXrpcError( 135 - WithTag("AccessControl"), 136 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 - ) 138 - } 139 - 140 45 // this is slightly different from http_util::write_error to follow the spec: 141 46 // 142 47 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 48 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 144 49 w.Header().Set("Content-Type", "application/json") 145 50 w.WriteHeader(status) 146 51 json.NewEncoder(w).Encode(e)
+1 -3
tailwind.config.js
··· 36 36 css: { 37 37 maxWidth: "none", 38 38 pre: { 39 - backgroundColor: colors.gray[100], 40 - color: colors.black, 41 - "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 42 40 }, 43 41 code: { 44 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+62 -41
workflow/compile.go
··· 1 1 package workflow 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" 7 8 ) 8 9 10 + type RawWorkflow struct { 11 + Name string 12 + Contents []byte 13 + } 14 + 15 + type RawPipeline = []RawWorkflow 16 + 9 17 type Compiler struct { 10 18 Trigger tangled.Pipeline_TriggerMetadata 11 19 Diagnostics Diagnostics 12 20 } 13 21 14 22 type Diagnostics struct { 15 - Errors []error 23 + Errors []Error 16 24 Warnings []Warning 17 25 } 18 26 27 + func (d *Diagnostics) IsEmpty() bool { 28 + return len(d.Errors) == 0 && len(d.Warnings) == 0 29 + } 30 + 19 31 func (d *Diagnostics) Combine(o Diagnostics) { 20 32 d.Errors = append(d.Errors, o.Errors...) 21 33 d.Warnings = append(d.Warnings, o.Warnings...) ··· 25 37 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 26 38 } 27 39 28 - func (d *Diagnostics) AddError(err error) { 29 - d.Errors = append(d.Errors, err) 40 + func (d *Diagnostics) AddError(path string, err error) { 41 + d.Errors = append(d.Errors, Error{path, err}) 30 42 } 31 43 32 44 func (d Diagnostics) IsErr() bool { 33 45 return len(d.Errors) != 0 34 46 } 35 47 48 + type Error struct { 49 + Path string 50 + Error error 51 + } 52 + 53 + func (e Error) String() string { 54 + return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error()) 55 + } 56 + 36 57 type Warning struct { 37 58 Path string 38 59 Type WarningKind 39 60 Reason string 40 61 } 41 62 63 + func (w Warning) String() string { 64 + return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason) 65 + } 66 + 67 + var ( 68 + MissingEngine error = errors.New("missing engine") 69 + ) 70 + 42 71 type WarningKind string 43 72 44 73 var ( ··· 46 75 InvalidConfiguration WarningKind = "invalid configuration" 47 76 ) 48 77 78 + func (compiler *Compiler) Parse(p RawPipeline) Pipeline { 79 + var pp Pipeline 80 + 81 + for _, w := range p { 82 + wf, err := FromFile(w.Name, w.Contents) 83 + if err != nil { 84 + compiler.Diagnostics.AddError(w.Name, err) 85 + continue 86 + } 87 + 88 + pp = append(pp, wf) 89 + } 90 + 91 + return pp 92 + } 93 + 49 94 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 50 95 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 51 96 cp := tangled.Pipeline{ 52 97 TriggerMetadata: &compiler.Trigger, 53 98 } 54 99 55 - for _, w := range p { 56 - cw := compiler.compileWorkflow(w) 100 + for _, wf := range p { 101 + cw := compiler.compileWorkflow(wf) 57 102 58 - // empty workflows are not added to the pipeline 59 - if len(cw.Steps) == 0 { 103 + if cw == nil { 60 104 continue 61 105 } 62 106 63 - cp.Workflows = append(cp.Workflows, &cw) 107 + cp.Workflows = append(cp.Workflows, cw) 64 108 } 65 109 66 110 return cp 67 111 } 68 112 69 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 70 - cw := tangled.Pipeline_Workflow{} 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 71 115 72 116 if !w.Match(compiler.Trigger) { 73 117 compiler.Diagnostics.AddWarning( ··· 75 119 WorkflowSkipped, 76 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 77 121 ) 78 - return cw 79 - } 80 - 81 - if len(w.Steps) == 0 { 82 - compiler.Diagnostics.AddWarning( 83 - w.Name, 84 - WorkflowSkipped, 85 - "empty workflow", 86 - ) 87 - return cw 122 + return nil 88 123 } 89 124 90 125 // validate clone options 91 126 compiler.analyzeCloneOptions(w) 92 127 93 128 cw.Name = w.Name 94 - cw.Dependencies = w.Dependencies.AsRecord() 95 - for _, s := range w.Steps { 96 - step := tangled.Pipeline_Step{ 97 - Command: s.Command, 98 - Name: s.Name, 99 - } 100 - for k, v := range s.Environment { 101 - e := &tangled.Pipeline_Pair{ 102 - Key: k, 103 - Value: v, 104 - } 105 - step.Environment = append(step.Environment, e) 106 - } 107 - cw.Steps = append(cw.Steps, &step) 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 108 133 } 109 - for k, v := range w.Environment { 110 - e := &tangled.Pipeline_Pair{ 111 - Key: k, 112 - Value: v, 113 - } 114 - cw.Environment = append(cw.Environment, e) 115 - } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 116 137 117 138 o := w.CloneOpts.AsRecord() 118 139 cw.Clone = &o
+23 -29
workflow/compile_test.go
··· 26 26 27 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 34 32 CloneOpts: CloneOpts{}, // default true 35 33 } 36 34 ··· 43 41 assert.False(t, c.Diagnostics.IsErr()) 44 42 } 45 43 46 - func TestCompileWorkflow_EmptySteps(t *testing.T) { 47 - wf := Workflow{ 48 - Name: ".tangled/workflows/empty.yml", 49 - When: when, 50 - Steps: []Step{}, // no steps 51 - } 52 - 53 - c := Compiler{Trigger: trigger} 54 - cp := c.Compile([]Workflow{wf}) 55 - 56 - assert.Len(t, cp.Workflows, 0) 57 - assert.Len(t, c.Diagnostics.Warnings, 1) 58 - assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type) 59 - } 60 - 61 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 45 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 64 48 When: []Constraint{ 65 49 { 66 50 Event: []string{"push"}, 67 51 Branch: []string{"master"}, // different branch 68 52 }, 69 53 }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 - }, 73 54 } 74 55 75 56 c := Compiler{Trigger: trigger} ··· 82 63 83 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 65 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 90 69 CloneOpts: CloneOpts{ 91 70 Skip: true, 92 71 Depth: 1, ··· 101 80 assert.Len(t, c.Diagnostics.Warnings, 1) 102 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 82 } 83 + 84 + func TestCompileWorkflow_MissingEngine(t *testing.T) { 85 + wf := Workflow{ 86 + Name: ".tangled/workflows/missing_engine.yml", 87 + When: when, 88 + Engine: "", 89 + } 90 + 91 + c := Compiler{Trigger: trigger} 92 + cp := c.Compile([]Workflow{wf}) 93 + 94 + assert.Len(t, cp.Workflows, 0) 95 + assert.Len(t, c.Diagnostics.Errors, 1) 96 + assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 + }
+6 -33
workflow/def.go
··· 24 24 25 25 // this is simply a structural representation of the workflow file 26 26 Workflow struct { 27 - Name string `yaml:"-"` // name of the workflow file 28 - When []Constraint `yaml:"when"` 29 - Dependencies Dependencies `yaml:"dependencies"` 30 - Steps []Step `yaml:"steps"` 31 - Environment map[string]string `yaml:"environment"` 32 - CloneOpts CloneOpts `yaml:"clone"` 27 + Name string `yaml:"-"` // name of the workflow file 28 + Engine string `yaml:"engine"` 29 + When []Constraint `yaml:"when"` 30 + CloneOpts CloneOpts `yaml:"clone"` 31 + Raw string `yaml:"-"` 33 32 } 34 33 35 34 Constraint struct { 36 35 Event StringList `yaml:"event"` 37 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 38 37 } 39 - 40 - Dependencies map[string][]string 41 38 42 39 CloneOpts struct { 43 40 Skip bool `yaml:"skip"` 44 41 Depth int `yaml:"depth"` 45 42 IncludeSubmodules bool `yaml:"submodules"` 46 - } 47 - 48 - Step struct { 49 - Name string `yaml:"name"` 50 - Command string `yaml:"command"` 51 - Environment map[string]string `yaml:"environment"` 52 43 } 53 44 54 45 StringList []string ··· 77 68 } 78 69 79 70 wf.Name = name 71 + wf.Raw = string(contents) 80 72 81 73 return wf, nil 82 74 } ··· 173 165 } 174 166 175 167 return errors.New("failed to unmarshal StringOrSlice") 176 - } 177 - 178 - // conversion utilities to atproto records 179 - func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency { 180 - var deps []*tangled.Pipeline_Dependency 181 - for registry, packages := range d { 182 - deps = append(deps, &tangled.Pipeline_Dependency{ 183 - Registry: registry, 184 - Packages: packages, 185 - }) 186 - } 187 - return deps 188 - } 189 - 190 - func (s Step) AsRecord() tangled.Pipeline_Step { 191 - return tangled.Pipeline_Step{ 192 - Command: s.Command, 193 - Name: s.Name, 194 - } 195 168 } 196 169 197 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] 13 - branch: ["main", "develop"] 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go 18 - - git 19 - - curl 20 - 21 - steps: 22 - - name: "Test" 23 - command: | 24 - go test ./...` 13 + branch: ["main", "develop"]` 25 14 26 15 wf, err := FromFile("test.yml", []byte(yamlData)) 27 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 21 33 - assert.Len(t, wf.Steps, 1) 34 - assert.Equal(t, "Test", wf.Steps[0].Name) 35 - assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 - 37 - pkgs, ok := wf.Dependencies["nixpkgs"] 38 - assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 - assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 - 41 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 23 } 43 24 44 - func TestUnmarshalCustomRegistry(t *testing.T) { 45 - yamlData := ` 46 - when: 47 - - event: push 48 - branch: main 49 - 50 - dependencies: 51 - git+https://tangled.sh/@oppi.li/tbsp: 52 - - tbsp 53 - git+https://git.peppe.rs/languages/statix: 54 - - statix 55 - 56 - steps: 57 - - name: "Check" 58 - command: | 59 - statix check` 60 - 61 - wf, err := FromFile("test.yml", []byte(yamlData)) 62 - assert.NoError(t, err, "YAML should unmarshal without error") 63 - 64 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 - assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 - 67 - assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 - assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 - } 70 - 71 25 func TestUnmarshalCloneFalse(t *testing.T) { 72 26 yamlData := ` 73 27 when: ··· 75 29 76 30 clone: 77 31 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 32 ` 88 33 89 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 38 94 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 40 } 96 - 97 - func TestUnmarshalEnv(t *testing.T) { 98 - yamlData := ` 99 - when: 100 - - event: ["pull_request_close"] 101 - 102 - clone: 103 - skip: false 104 - 105 - environment: 106 - HOME: /home/foo bar/baz 107 - CGO_ENABLED: 1 108 - 109 - steps: 110 - - name: Something 111 - command: echo "hello" 112 - environment: 113 - FOO: bar 114 - BAZ: qux 115 - ` 116 - 117 - wf, err := FromFile("test.yml", []byte(yamlData)) 118 - assert.NoError(t, err) 119 - 120 - assert.Len(t, wf.Environment, 2) 121 - assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 122 - assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 123 - assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"]) 124 - assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"]) 125 - }
+110
xrpc/errors/errors.go
··· 1 + package errors 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + type XrpcError struct { 9 + Tag string `json:"error"` 10 + Message string `json:"message"` 11 + } 12 + 13 + func (x XrpcError) Error() string { 14 + if x.Message != "" { 15 + return fmt.Sprintf("%s: %s", x.Tag, x.Message) 16 + } 17 + return x.Tag 18 + } 19 + 20 + func NewXrpcError(opts ...ErrOpt) XrpcError { 21 + x := XrpcError{} 22 + for _, o := range opts { 23 + o(&x) 24 + } 25 + 26 + return x 27 + } 28 + 29 + type ErrOpt = func(xerr *XrpcError) 30 + 31 + func WithTag(tag string) ErrOpt { 32 + return func(xerr *XrpcError) { 33 + xerr.Tag = tag 34 + } 35 + } 36 + 37 + func WithMessage[S ~string](s S) ErrOpt { 38 + return func(xerr *XrpcError) { 39 + xerr.Message = string(s) 40 + } 41 + } 42 + 43 + func WithError(e error) ErrOpt { 44 + return func(xerr *XrpcError) { 45 + xerr.Message = e.Error() 46 + } 47 + } 48 + 49 + var MissingActorDidError = NewXrpcError( 50 + WithTag("MissingActorDid"), 51 + WithMessage("actor DID not supplied"), 52 + ) 53 + 54 + var AuthError = func(err error) XrpcError { 55 + return NewXrpcError( 56 + WithTag("Auth"), 57 + WithError(fmt.Errorf("signature verification failed: %w", err)), 58 + ) 59 + } 60 + 61 + var InvalidRepoError = func(r string) XrpcError { 62 + return NewXrpcError( 63 + WithTag("InvalidRepo"), 64 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 65 + ) 66 + } 67 + 68 + var GitError = func(e error) XrpcError { 69 + return NewXrpcError( 70 + WithTag("Git"), 71 + WithError(fmt.Errorf("git error: %w", e)), 72 + ) 73 + } 74 + 75 + var AccessControlError = func(d string) XrpcError { 76 + return NewXrpcError( 77 + WithTag("AccessControl"), 78 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 79 + ) 80 + } 81 + 82 + var RepoExistsError = func(r string) XrpcError { 83 + return NewXrpcError( 84 + WithTag("RepoExists"), 85 + WithError(fmt.Errorf("repo already exists: %s", r)), 86 + ) 87 + } 88 + 89 + var RecordExistsError = func(r string) XrpcError { 90 + return NewXrpcError( 91 + WithTag("RecordExists"), 92 + WithError(fmt.Errorf("repo already exists: %s", r)), 93 + ) 94 + } 95 + 96 + func GenericError(err error) XrpcError { 97 + return NewXrpcError( 98 + WithTag("Generic"), 99 + WithError(err), 100 + ) 101 + } 102 + 103 + func Unmarshal(errStr string) (XrpcError, error) { 104 + var xerr XrpcError 105 + err := json.Unmarshal([]byte(errStr), &xerr) 106 + if err != nil { 107 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 108 + } 109 + return xerr, nil 110 + }
+65
xrpc/serviceauth/service_auth.go
··· 1 + package serviceauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + const ActorDid string = "ActorDid" 16 + 17 + type ServiceAuth struct { 18 + logger *slog.Logger 19 + resolver *idresolver.Resolver 20 + audienceDid string 21 + } 22 + 23 + func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 + return &ServiceAuth{ 25 + logger: logger, 26 + resolver: resolver, 27 + audienceDid: audienceDid, 28 + } 29 + } 30 + 31 + func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + l := sa.logger.With("url", r.URL) 34 + 35 + token := r.Header.Get("Authorization") 36 + token = strings.TrimPrefix(token, "Bearer ") 37 + 38 + s := auth.ServiceAuthValidator{ 39 + Audience: sa.audienceDid, 40 + Dir: sa.resolver.Directory(), 41 + } 42 + 43 + did, err := s.Validate(r.Context(), token, nil) 44 + if err != nil { 45 + l.Error("signature verification failed", "err", err) 46 + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 + return 48 + } 49 + 50 + r = r.WithContext( 51 + context.WithValue(r.Context(), ActorDid, did), 52 + ) 53 + 54 + next.ServeHTTP(w, r) 55 + }) 56 + } 57 + 58 + // this is slightly different from http_util::write_error to follow the spec: 59 + // 60 + // the json object returned must include an "error" and a "message" 61 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 62 + w.Header().Set("Content-Type", "application/json") 63 + w.WriteHeader(status) 64 + json.NewEncoder(w).Encode(e) 65 + }