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

Compare changes

Choose any two refs to compare.

Changed files
+12426 -3208
.tangled
workflows
api
appview
config
db
dns
idresolver
issues
knots
middleware
notify
oauth
pages
pipelines
posthog
pulls
repo
reporesolver
settings
signup
spindles
state
xrpcclient
avatar
src
cmd
docs
guard
hook
idresolver
jetstream
knotserver
lexicons
nix
patchutil
rbac
spindle
types
+2 -1
.tangled/workflows/fmt.yml
··· 14 15 - name: "go fmt" 16 command: | 17 - gofmt -l . 18
··· 14 15 - name: "go fmt" 16 command: | 17 + unformatted=$(gofmt -l .) 18 + test -z "$unformatted" || (echo "$unformatted" && exit 1) 19
+740 -2
api/tangled/cbor_gen.go
··· 504 505 return nil 506 } 507 func (t *FeedStar) MarshalCBOR(w io.Writer) error { 508 if t == nil { 509 _, err := w.Write(cbg.CborNull) ··· 1011 } 1012 1013 cw := cbg.NewCborWriter(w) 1014 1015 - if _, err := cw.Write([]byte{162}); err != nil { 1016 return err 1017 } 1018 ··· 1047 if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1048 return err 1049 } 1050 return nil 1051 } 1052 ··· 1075 1076 n := extra 1077 1078 - nameBuf := make([]byte, 12) 1079 for i := uint64(0); i < n; i++ { 1080 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1081 if err != nil { ··· 1128 t.IsDefaultRef = true 1129 default: 1130 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1131 } 1132 1133 default: ··· 1425 } 1426 1427 t.Email = string(sval) 1428 } 1429 1430 default: ··· 5291 } 5292 } 5293 5294 } 5295 // t.CreatedAt (string) (string) 5296 case "createdAt":
··· 504 505 return nil 506 } 507 + func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 508 + if t == nil { 509 + _, err := w.Write(cbg.CborNull) 510 + return err 511 + } 512 + 513 + cw := cbg.NewCborWriter(w) 514 + 515 + if _, err := cw.Write([]byte{164}); err != nil { 516 + return err 517 + } 518 + 519 + // t.LexiconTypeID (string) (string) 520 + if len("$type") > 1000000 { 521 + return xerrors.Errorf("Value in field \"$type\" was too long") 522 + } 523 + 524 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 525 + return err 526 + } 527 + if _, err := cw.WriteString(string("$type")); err != nil { 528 + return err 529 + } 530 + 531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil { 532 + return err 533 + } 534 + if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil { 535 + return err 536 + } 537 + 538 + // t.Subject (string) (string) 539 + if len("subject") > 1000000 { 540 + return xerrors.Errorf("Value in field \"subject\" was too long") 541 + } 542 + 543 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 544 + return err 545 + } 546 + if _, err := cw.WriteString(string("subject")); err != nil { 547 + return err 548 + } 549 + 550 + if len(t.Subject) > 1000000 { 551 + return xerrors.Errorf("Value in field t.Subject was too long") 552 + } 553 + 554 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 555 + return err 556 + } 557 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 558 + return err 559 + } 560 + 561 + // t.Reaction (string) (string) 562 + if len("reaction") > 1000000 { 563 + return xerrors.Errorf("Value in field \"reaction\" was too long") 564 + } 565 + 566 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reaction"))); err != nil { 567 + return err 568 + } 569 + if _, err := cw.WriteString(string("reaction")); err != nil { 570 + return err 571 + } 572 + 573 + if len(t.Reaction) > 1000000 { 574 + return xerrors.Errorf("Value in field t.Reaction was too long") 575 + } 576 + 577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Reaction))); err != nil { 578 + return err 579 + } 580 + if _, err := cw.WriteString(string(t.Reaction)); err != nil { 581 + return err 582 + } 583 + 584 + // t.CreatedAt (string) (string) 585 + if len("createdAt") > 1000000 { 586 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 587 + } 588 + 589 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 590 + return err 591 + } 592 + if _, err := cw.WriteString(string("createdAt")); err != nil { 593 + return err 594 + } 595 + 596 + if len(t.CreatedAt) > 1000000 { 597 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 598 + } 599 + 600 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 601 + return err 602 + } 603 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 604 + return err 605 + } 606 + return nil 607 + } 608 + 609 + func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) { 610 + *t = FeedReaction{} 611 + 612 + cr := cbg.NewCborReader(r) 613 + 614 + maj, extra, err := cr.ReadHeader() 615 + if err != nil { 616 + return err 617 + } 618 + defer func() { 619 + if err == io.EOF { 620 + err = io.ErrUnexpectedEOF 621 + } 622 + }() 623 + 624 + if maj != cbg.MajMap { 625 + return fmt.Errorf("cbor input should be of type map") 626 + } 627 + 628 + if extra > cbg.MaxLength { 629 + return fmt.Errorf("FeedReaction: map struct too large (%d)", extra) 630 + } 631 + 632 + n := extra 633 + 634 + nameBuf := make([]byte, 9) 635 + for i := uint64(0); i < n; i++ { 636 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 637 + if err != nil { 638 + return err 639 + } 640 + 641 + if !ok { 642 + // Field doesn't exist on this type, so ignore it 643 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 644 + return err 645 + } 646 + continue 647 + } 648 + 649 + switch string(nameBuf[:nameLen]) { 650 + // t.LexiconTypeID (string) (string) 651 + case "$type": 652 + 653 + { 654 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 655 + if err != nil { 656 + return err 657 + } 658 + 659 + t.LexiconTypeID = string(sval) 660 + } 661 + // t.Subject (string) (string) 662 + case "subject": 663 + 664 + { 665 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 666 + if err != nil { 667 + return err 668 + } 669 + 670 + t.Subject = string(sval) 671 + } 672 + // t.Reaction (string) (string) 673 + case "reaction": 674 + 675 + { 676 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 677 + if err != nil { 678 + return err 679 + } 680 + 681 + t.Reaction = string(sval) 682 + } 683 + // t.CreatedAt (string) (string) 684 + case "createdAt": 685 + 686 + { 687 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 688 + if err != nil { 689 + return err 690 + } 691 + 692 + t.CreatedAt = string(sval) 693 + } 694 + 695 + default: 696 + // Field doesn't exist on this type, so ignore it 697 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 698 + return err 699 + } 700 + } 701 + } 702 + 703 + return nil 704 + } 705 func (t *FeedStar) MarshalCBOR(w io.Writer) error { 706 if t == nil { 707 _, err := w.Write(cbg.CborNull) ··· 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 ··· 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 ··· 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 { ··· 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: ··· 1667 } 1668 1669 t.Email = string(sval) 1670 + } 1671 + 1672 + default: 1673 + // Field doesn't exist on this type, so ignore it 1674 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1675 + return err 1676 + } 1677 + } 1678 + } 1679 + 1680 + return nil 1681 + } 1682 + func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error { 1683 + if t == nil { 1684 + _, err := w.Write(cbg.CborNull) 1685 + return err 1686 + } 1687 + 1688 + cw := cbg.NewCborWriter(w) 1689 + fieldCount := 1 1690 + 1691 + if t.Inputs == nil { 1692 + fieldCount-- 1693 + } 1694 + 1695 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1696 + return err 1697 + } 1698 + 1699 + // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1700 + if t.Inputs != nil { 1701 + 1702 + if len("inputs") > 1000000 { 1703 + return xerrors.Errorf("Value in field \"inputs\" was too long") 1704 + } 1705 + 1706 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { 1707 + return err 1708 + } 1709 + if _, err := cw.WriteString(string("inputs")); err != nil { 1710 + return err 1711 + } 1712 + 1713 + if len(t.Inputs) > 8192 { 1714 + return xerrors.Errorf("Slice value in field t.Inputs was too long") 1715 + } 1716 + 1717 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1718 + return err 1719 + } 1720 + for _, v := range t.Inputs { 1721 + if err := v.MarshalCBOR(cw); err != nil { 1722 + return err 1723 + } 1724 + 1725 + } 1726 + } 1727 + return nil 1728 + } 1729 + 1730 + func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1731 + *t = GitRefUpdate_Meta_LangBreakdown{} 1732 + 1733 + cr := cbg.NewCborReader(r) 1734 + 1735 + maj, extra, err := cr.ReadHeader() 1736 + if err != nil { 1737 + return err 1738 + } 1739 + defer func() { 1740 + if err == io.EOF { 1741 + err = io.ErrUnexpectedEOF 1742 + } 1743 + }() 1744 + 1745 + if maj != cbg.MajMap { 1746 + return fmt.Errorf("cbor input should be of type map") 1747 + } 1748 + 1749 + if extra > cbg.MaxLength { 1750 + return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra) 1751 + } 1752 + 1753 + n := extra 1754 + 1755 + nameBuf := make([]byte, 6) 1756 + for i := uint64(0); i < n; i++ { 1757 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1758 + if err != nil { 1759 + return err 1760 + } 1761 + 1762 + if !ok { 1763 + // Field doesn't exist on this type, so ignore it 1764 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1765 + return err 1766 + } 1767 + continue 1768 + } 1769 + 1770 + switch string(nameBuf[:nameLen]) { 1771 + // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1772 + case "inputs": 1773 + 1774 + maj, extra, err = cr.ReadHeader() 1775 + if err != nil { 1776 + return err 1777 + } 1778 + 1779 + if extra > 8192 { 1780 + return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1781 + } 1782 + 1783 + if maj != cbg.MajArray { 1784 + return fmt.Errorf("expected cbor array") 1785 + } 1786 + 1787 + if extra > 0 { 1788 + t.Inputs = make([]*GitRefUpdate_Pair, extra) 1789 + } 1790 + 1791 + for i := 0; i < int(extra); i++ { 1792 + { 1793 + var maj byte 1794 + var extra uint64 1795 + var err error 1796 + _ = maj 1797 + _ = extra 1798 + _ = err 1799 + 1800 + { 1801 + 1802 + b, err := cr.ReadByte() 1803 + if err != nil { 1804 + return err 1805 + } 1806 + if b != cbg.CborNull[0] { 1807 + if err := cr.UnreadByte(); err != nil { 1808 + return err 1809 + } 1810 + t.Inputs[i] = new(GitRefUpdate_Pair) 1811 + if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1812 + return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1813 + } 1814 + } 1815 + 1816 + } 1817 + 1818 + } 1819 + } 1820 + 1821 + default: 1822 + // Field doesn't exist on this type, so ignore it 1823 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1824 + return err 1825 + } 1826 + } 1827 + } 1828 + 1829 + return nil 1830 + } 1831 + func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error { 1832 + if t == nil { 1833 + _, err := w.Write(cbg.CborNull) 1834 + return err 1835 + } 1836 + 1837 + cw := cbg.NewCborWriter(w) 1838 + 1839 + if _, err := cw.Write([]byte{162}); err != nil { 1840 + return err 1841 + } 1842 + 1843 + // t.Lang (string) (string) 1844 + if len("lang") > 1000000 { 1845 + return xerrors.Errorf("Value in field \"lang\" was too long") 1846 + } 1847 + 1848 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil { 1849 + return err 1850 + } 1851 + if _, err := cw.WriteString(string("lang")); err != nil { 1852 + return err 1853 + } 1854 + 1855 + if len(t.Lang) > 1000000 { 1856 + return xerrors.Errorf("Value in field t.Lang was too long") 1857 + } 1858 + 1859 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil { 1860 + return err 1861 + } 1862 + if _, err := cw.WriteString(string(t.Lang)); err != nil { 1863 + return err 1864 + } 1865 + 1866 + // t.Size (int64) (int64) 1867 + if len("size") > 1000000 { 1868 + return xerrors.Errorf("Value in field \"size\" was too long") 1869 + } 1870 + 1871 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1872 + return err 1873 + } 1874 + if _, err := cw.WriteString(string("size")); err != nil { 1875 + return err 1876 + } 1877 + 1878 + if t.Size >= 0 { 1879 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1880 + return err 1881 + } 1882 + } else { 1883 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1884 + return err 1885 + } 1886 + } 1887 + 1888 + return nil 1889 + } 1890 + 1891 + func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) { 1892 + *t = GitRefUpdate_Pair{} 1893 + 1894 + cr := cbg.NewCborReader(r) 1895 + 1896 + maj, extra, err := cr.ReadHeader() 1897 + if err != nil { 1898 + return err 1899 + } 1900 + defer func() { 1901 + if err == io.EOF { 1902 + err = io.ErrUnexpectedEOF 1903 + } 1904 + }() 1905 + 1906 + if maj != cbg.MajMap { 1907 + return fmt.Errorf("cbor input should be of type map") 1908 + } 1909 + 1910 + if extra > cbg.MaxLength { 1911 + return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra) 1912 + } 1913 + 1914 + n := extra 1915 + 1916 + nameBuf := make([]byte, 4) 1917 + for i := uint64(0); i < n; i++ { 1918 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1919 + if err != nil { 1920 + return err 1921 + } 1922 + 1923 + if !ok { 1924 + // Field doesn't exist on this type, so ignore it 1925 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1926 + return err 1927 + } 1928 + continue 1929 + } 1930 + 1931 + switch string(nameBuf[:nameLen]) { 1932 + // t.Lang (string) (string) 1933 + case "lang": 1934 + 1935 + { 1936 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1937 + if err != nil { 1938 + return err 1939 + } 1940 + 1941 + t.Lang = string(sval) 1942 + } 1943 + // t.Size (int64) (int64) 1944 + case "size": 1945 + { 1946 + maj, extra, err := cr.ReadHeader() 1947 + if err != nil { 1948 + return err 1949 + } 1950 + var extraI int64 1951 + switch maj { 1952 + case cbg.MajUnsignedInt: 1953 + extraI = int64(extra) 1954 + if extraI < 0 { 1955 + return fmt.Errorf("int64 positive overflow") 1956 + } 1957 + case cbg.MajNegativeInt: 1958 + extraI = int64(extra) 1959 + if extraI < 0 { 1960 + return fmt.Errorf("int64 negative overflow") 1961 + } 1962 + extraI = -1 - extraI 1963 + default: 1964 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1965 + } 1966 + 1967 + t.Size = int64(extraI) 1968 } 1969 1970 default: ··· 5831 } 5832 } 5833 5834 + } 5835 + // t.CreatedAt (string) (string) 5836 + case "createdAt": 5837 + 5838 + { 5839 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5840 + if err != nil { 5841 + return err 5842 + } 5843 + 5844 + t.CreatedAt = string(sval) 5845 + } 5846 + 5847 + default: 5848 + // Field doesn't exist on this type, so ignore it 5849 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5850 + return err 5851 + } 5852 + } 5853 + } 5854 + 5855 + return nil 5856 + } 5857 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5858 + if t == nil { 5859 + _, err := w.Write(cbg.CborNull) 5860 + return err 5861 + } 5862 + 5863 + cw := cbg.NewCborWriter(w) 5864 + 5865 + if _, err := cw.Write([]byte{164}); err != nil { 5866 + return err 5867 + } 5868 + 5869 + // t.Repo (string) (string) 5870 + if len("repo") > 1000000 { 5871 + return xerrors.Errorf("Value in field \"repo\" was too long") 5872 + } 5873 + 5874 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5875 + return err 5876 + } 5877 + if _, err := cw.WriteString(string("repo")); err != nil { 5878 + return err 5879 + } 5880 + 5881 + if len(t.Repo) > 1000000 { 5882 + return xerrors.Errorf("Value in field t.Repo was too long") 5883 + } 5884 + 5885 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5886 + return err 5887 + } 5888 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5889 + return err 5890 + } 5891 + 5892 + // t.LexiconTypeID (string) (string) 5893 + if len("$type") > 1000000 { 5894 + return xerrors.Errorf("Value in field \"$type\" was too long") 5895 + } 5896 + 5897 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5898 + return err 5899 + } 5900 + if _, err := cw.WriteString(string("$type")); err != nil { 5901 + return err 5902 + } 5903 + 5904 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5905 + return err 5906 + } 5907 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5908 + return err 5909 + } 5910 + 5911 + // t.Subject (string) (string) 5912 + if len("subject") > 1000000 { 5913 + return xerrors.Errorf("Value in field \"subject\" was too long") 5914 + } 5915 + 5916 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5917 + return err 5918 + } 5919 + if _, err := cw.WriteString(string("subject")); err != nil { 5920 + return err 5921 + } 5922 + 5923 + if len(t.Subject) > 1000000 { 5924 + return xerrors.Errorf("Value in field t.Subject was too long") 5925 + } 5926 + 5927 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5928 + return err 5929 + } 5930 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5931 + return err 5932 + } 5933 + 5934 + // t.CreatedAt (string) (string) 5935 + if len("createdAt") > 1000000 { 5936 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5937 + } 5938 + 5939 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5940 + return err 5941 + } 5942 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5943 + return err 5944 + } 5945 + 5946 + if len(t.CreatedAt) > 1000000 { 5947 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5948 + } 5949 + 5950 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5951 + return err 5952 + } 5953 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5954 + return err 5955 + } 5956 + return nil 5957 + } 5958 + 5959 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5960 + *t = RepoCollaborator{} 5961 + 5962 + cr := cbg.NewCborReader(r) 5963 + 5964 + maj, extra, err := cr.ReadHeader() 5965 + if err != nil { 5966 + return err 5967 + } 5968 + defer func() { 5969 + if err == io.EOF { 5970 + err = io.ErrUnexpectedEOF 5971 + } 5972 + }() 5973 + 5974 + if maj != cbg.MajMap { 5975 + return fmt.Errorf("cbor input should be of type map") 5976 + } 5977 + 5978 + if extra > cbg.MaxLength { 5979 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5980 + } 5981 + 5982 + n := extra 5983 + 5984 + nameBuf := make([]byte, 9) 5985 + for i := uint64(0); i < n; i++ { 5986 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5987 + if err != nil { 5988 + return err 5989 + } 5990 + 5991 + if !ok { 5992 + // Field doesn't exist on this type, so ignore it 5993 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5994 + return err 5995 + } 5996 + continue 5997 + } 5998 + 5999 + switch string(nameBuf[:nameLen]) { 6000 + // t.Repo (string) (string) 6001 + case "repo": 6002 + 6003 + { 6004 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6005 + if err != nil { 6006 + return err 6007 + } 6008 + 6009 + t.Repo = string(sval) 6010 + } 6011 + // t.LexiconTypeID (string) (string) 6012 + case "$type": 6013 + 6014 + { 6015 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6016 + if err != nil { 6017 + return err 6018 + } 6019 + 6020 + t.LexiconTypeID = string(sval) 6021 + } 6022 + // t.Subject (string) (string) 6023 + case "subject": 6024 + 6025 + { 6026 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6027 + if err != nil { 6028 + return err 6029 + } 6030 + 6031 + t.Subject = string(sval) 6032 } 6033 // t.CreatedAt (string) (string) 6034 case "createdAt":
+24
api/tangled/feedreaction.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.feed.reaction 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + FeedReactionNSID = "sh.tangled.feed.reaction" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{}) 17 + } // 18 + // RECORDTYPE: FeedReaction 19 + type FeedReaction struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + Reaction string `json:"reaction" cborgen:"reaction"` 23 + Subject string `json:"subject" cborgen:"subject"` 24 + }
+13 -2
api/tangled/gitrefUpdate.go
··· 34 } 35 36 type GitRefUpdate_Meta struct { 37 - CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 - IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 39 } 40 41 type GitRefUpdate_Meta_CommitCount struct { ··· 46 Count int64 `json:"count" cborgen:"count"` 47 Email string `json:"email" cborgen:"email"` 48 }
··· 34 } 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"` 40 } 41 42 type GitRefUpdate_Meta_CommitCount struct { ··· 47 Count int64 `json:"count" cborgen:"count"` 48 Email string `json:"email" cborgen:"email"` 49 } 50 + 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 { 57 + Lang string `json:"lang" cborgen:"lang"` 58 + Size int64 `json:"size" cborgen:"size"` 59 + }
+31
api/tangled/repoaddSecret.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.addSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoAddSecretNSID = "sh.tangled.repo.addSecret" 15 + ) 16 + 17 + // RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call. 18 + type RepoAddSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + Value string `json:"value" cborgen:"value"` 22 + } 23 + 24 + // RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret". 25 + func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error { 26 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil { 27 + return err 28 + } 29 + 30 + return nil 31 + }
+25
api/tangled/repocollaborator.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.collaborator 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoCollaboratorNSID = "sh.tangled.repo.collaborator" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) 17 + } // 18 + // RECORDTYPE: RepoCollaborator 19 + type RepoCollaborator struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // repo: repo to add this user to 23 + Repo string `json:"repo" cborgen:"repo"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 + }
+41
api/tangled/repolistSecrets.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.listSecrets 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoListSecretsNSID = "sh.tangled.repo.listSecrets" 15 + ) 16 + 17 + // RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call. 18 + type RepoListSecrets_Output struct { 19 + Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"` 20 + } 21 + 22 + // RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema. 23 + type RepoListSecrets_Secret struct { 24 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 25 + CreatedBy string `json:"createdBy" cborgen:"createdBy"` 26 + Key string `json:"key" cborgen:"key"` 27 + Repo string `json:"repo" cborgen:"repo"` 28 + } 29 + 30 + // RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets". 31 + func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) { 32 + var out RepoListSecrets_Output 33 + 34 + params := map[string]interface{}{} 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil { 37 + return nil, err 38 + } 39 + 40 + return &out, nil 41 + }
+30
api/tangled/reporemoveSecret.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.removeSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret" 15 + ) 16 + 17 + // RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call. 18 + type RepoRemoveSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret". 24 + func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+30
api/tangled/reposetDefaultBranch.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.setDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch" 15 + ) 16 + 17 + // RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call. 18 + type RepoSetDefaultBranch_Input struct { 19 + DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch". 24 + func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+3 -1
api/tangled/stateclosed.go
··· 4 5 // schema: sh.tangled.repo.issue.state.closed 6 7 - const () 8 9 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
··· 4 5 // schema: sh.tangled.repo.issue.state.closed 6 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 10 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 5 // schema: sh.tangled.repo.issue.state.open 6 7 - const () 8 9 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
··· 4 5 // schema: sh.tangled.repo.issue.state.open 6 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 10 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 5 // schema: sh.tangled.repo.pull.status.closed 6 7 - const () 8 9 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
··· 4 5 // schema: sh.tangled.repo.pull.status.closed 6 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 10 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 5 // schema: sh.tangled.repo.pull.status.merged 6 7 - const () 8 9 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
··· 4 5 // schema: sh.tangled.repo.pull.status.merged 6 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 10 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 5 // schema: sh.tangled.repo.pull.status.open 6 7 - const () 8 9 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
··· 4 5 // schema: sh.tangled.repo.pull.status.open 6 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 10 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+18 -5
appview/config/config.go
··· 10 ) 11 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 18 } 19 20 type OAuthConfig struct { ··· 59 DB int `env:"DB, default=0"` 60 } 61 62 func (cfg RedisConfig) ToURL() string { 63 u := &url.URL{ 64 Scheme: "redis", ··· 84 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 87 } 88 89 func LoadConfig(ctx context.Context) (*Config, error) {
··· 10 ) 11 12 type CoreConfig struct { 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 } 20 21 type OAuthConfig struct { ··· 60 DB int `env:"DB, default=0"` 61 } 62 63 + type PdsConfig struct { 64 + Host string `env:"HOST, default=https://tngl.sh"` 65 + AdminSecret string `env:"ADMIN_SECRET"` 66 + } 67 + 68 + type Cloudflare struct { 69 + ApiToken string `env:"API_TOKEN"` 70 + ZoneId string `env:"ZONE_ID"` 71 + } 72 + 73 func (cfg RedisConfig) ToURL() string { 74 u := &url.URL{ 75 Scheme: "redis", ··· 95 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 96 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 97 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 98 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 99 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 100 } 101 102 func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
···
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + type Collaborator struct { 12 + // identifiers for the record 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + 17 + // content 18 + SubjectDid syntax.DID 19 + RepoAt syntax.ATURI 20 + 21 + // meta 22 + Created time.Time 23 + } 24 + 25 + func AddCollaborator(e Execer, c Collaborator) error { 26 + _, err := e.Exec( 27 + `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 + c.Did, c.Rkey, c.SubjectDid, c.RepoAt, 29 + ) 30 + return err 31 + } 32 + 33 + func DeleteCollaborator(e Execer, filters ...filter) error { 34 + var conditions []string 35 + var args []any 36 + for _, filter := range filters { 37 + conditions = append(conditions, filter.Condition()) 38 + args = append(args, filter.Arg()...) 39 + } 40 + 41 + whereClause := "" 42 + if conditions != nil { 43 + whereClause = " where " + strings.Join(conditions, " and ") 44 + } 45 + 46 + query := fmt.Sprintf(`delete from collaborators %s`, whereClause) 47 + 48 + _, err := e.Exec(query, args...) 49 + return err 50 + } 51 + 52 + func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 + rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer rows.Close() 58 + 59 + var repoAts []string 60 + for rows.Next() { 61 + var aturi string 62 + err := rows.Scan(&aturi) 63 + if err != nil { 64 + return nil, err 65 + } 66 + repoAts = append(repoAts, aturi) 67 + } 68 + if err := rows.Err(); err != nil { 69 + return nil, err 70 + } 71 + if repoAts == nil { 72 + return nil, nil 73 + } 74 + 75 + return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 76 + }
+91 -3
appview/db/db.go
··· 199 unique(starred_by_did, repo_at) 200 ); 201 202 create table if not exists emails ( 203 id integer primary key autoincrement, 204 did text not null, ··· 345 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 346 347 -- constraints 348 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 349 unique (did, instance, subject) 350 ); 351 ··· 411 on delete cascade 412 ); 413 414 create table if not exists migrations ( 415 id integer primary key autoincrement, 416 name text unique ··· 553 return nil 554 }) 555 556 return &DB{db}, nil 557 } 558 ··· 628 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 629 if kind == reflect.Slice || kind == reflect.Array { 630 if rv.Len() == 0 { 631 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 632 } 633 634 placeholders := make([]string, rv.Len()) ··· 647 kind := rv.Kind() 648 if kind == reflect.Slice || kind == reflect.Array { 649 if rv.Len() == 0 { 650 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 651 } 652 653 out := make([]any, rv.Len())
··· 199 unique(starred_by_did, repo_at) 200 ); 201 202 + create table if not exists reactions ( 203 + id integer primary key autoincrement, 204 + reacted_by_did text not null, 205 + thread_at text not null, 206 + kind text not null, 207 + rkey text not null, 208 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 209 + unique(reacted_by_did, thread_at, kind) 210 + ); 211 + 212 create table if not exists emails ( 213 id integer primary key autoincrement, 214 did text not null, ··· 355 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 356 357 -- constraints 358 unique (did, instance, subject) 359 ); 360 ··· 420 on delete cascade 421 ); 422 423 + create table if not exists repo_languages ( 424 + -- identifiers 425 + id integer primary key autoincrement, 426 + 427 + -- repo identifiers 428 + repo_at text not null, 429 + ref text not null, 430 + is_default_ref integer not null default 0, 431 + 432 + -- language breakdown 433 + language text not null, 434 + bytes integer not null check (bytes >= 0), 435 + 436 + unique(repo_at, ref, language) 437 + ); 438 + 439 + create table if not exists signups_inflight ( 440 + id integer primary key autoincrement, 441 + email text not null unique, 442 + invite_code text not null, 443 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 444 + ); 445 + 446 create table if not exists migrations ( 447 id integer primary key autoincrement, 448 name text unique ··· 585 return nil 586 }) 587 588 + // recreate and add rkey + created columns with default constraint 589 + runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 590 + // create new table 591 + // - repo_at instead of repo integer 592 + // - rkey field 593 + // - created field 594 + _, err := tx.Exec(` 595 + create table collaborators_new ( 596 + -- identifiers for the record 597 + id integer primary key autoincrement, 598 + did text not null, 599 + rkey text, 600 + 601 + -- content 602 + subject_did text not null, 603 + repo_at text not null, 604 + 605 + -- meta 606 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 607 + 608 + -- constraints 609 + foreign key (repo_at) references repos(at_uri) on delete cascade 610 + ) 611 + `) 612 + if err != nil { 613 + return err 614 + } 615 + 616 + // copy data 617 + _, err = tx.Exec(` 618 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 619 + select 620 + c.id, 621 + r.did, 622 + '', 623 + c.did, 624 + r.at_uri 625 + from collaborators c 626 + join repos r on c.repo = r.id 627 + `) 628 + if err != nil { 629 + return err 630 + } 631 + 632 + // drop old table 633 + _, err = tx.Exec(`drop table collaborators`) 634 + if err != nil { 635 + return err 636 + } 637 + 638 + // rename new table 639 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 640 + return err 641 + }) 642 + 643 return &DB{db}, nil 644 } 645 ··· 715 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 716 if kind == reflect.Slice || kind == reflect.Array { 717 if rv.Len() == 0 { 718 + // always false 719 + return "1 = 0" 720 } 721 722 placeholders := make([]string, rv.Len()) ··· 735 kind := rv.Kind() 736 if kind == reflect.Slice || kind == reflect.Array { 737 if rv.Len() == 0 { 738 + return nil 739 } 740 741 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 query := ` 104 select email, did 105 from emails 106 - where 107 - verified = ? 108 and email in (` + strings.Join(placeholders, ",") + `) 109 ` 110 ··· 153 ` 154 var count int 155 err := e.QueryRow(query, did, email).Scan(&count) 156 if err != nil { 157 return false, err 158 }
··· 103 query := ` 104 select email, did 105 from emails 106 + where 107 + verified = ? 108 and email in (` + strings.Join(placeholders, ",") + `) 109 ` 110 ··· 153 ` 154 var count int 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 161 + 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 170 if err != nil { 171 return false, err 172 }
+2 -2
appview/db/follow.go
··· 12 Rkey string 13 } 14 15 - func AddFollow(e Execer, userDid, subjectDid, rkey string) error { 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 - _, err := e.Exec(query, userDid, subjectDid, rkey) 18 return err 19 } 20
··· 12 Rkey string 13 } 14 15 + func AddFollow(e Execer, follow *Follow) error { 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 + _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 18 return err 19 } 20
+17 -12
appview/db/issues.go
··· 9 ) 10 11 type Issue struct { 12 RepoAt syntax.ATURI 13 OwnerDid string 14 IssueId int ··· 65 66 issue.IssueId = nextId 67 68 - _, err = tx.Exec(` 69 insert into issues (repo_at, owner_did, issue_id, title, body) 70 values (?, ?, ?, ?, ?) 71 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 72 if err != nil { 73 return err 74 } 75 76 if err := tx.Commit(); err != nil { 77 return err ··· 89 var issueAt string 90 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 return issueAt, err 92 - } 93 - 94 - func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) { 95 - var issueId int 96 - err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId) 97 - return issueId - 1, err 98 } 99 100 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 114 ` 115 with numbered_issue as ( 116 select 117 i.owner_did, 118 i.issue_id, 119 i.created, ··· 132 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 ) 134 select 135 owner_did, 136 issue_id, 137 created, ··· 153 var issue Issue 154 var createdAt string 155 var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 if err != nil { 158 return nil, err 159 } ··· 182 183 rows, err := e.Query( 184 `select 185 i.owner_did, 186 i.repo_at, 187 i.issue_id, ··· 213 var issueCreatedAt, repoCreatedAt string 214 var repo Repo 215 err := rows.Scan( 216 &issue.OwnerDid, 217 &issue.RepoAt, 218 &issue.IssueId, ··· 257 } 258 259 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 260 - query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 261 row := e.QueryRow(query, repoAt, issueId) 262 263 var issue Issue 264 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 if err != nil { 267 return nil, err 268 } ··· 277 } 278 279 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 280 - query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 281 row := e.QueryRow(query, repoAt, issueId) 282 283 var issue Issue 284 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 286 if err != nil { 287 return nil, nil, err 288 }
··· 9 ) 10 11 type Issue struct { 12 + ID int64 13 RepoAt syntax.ATURI 14 OwnerDid string 15 IssueId int ··· 66 67 issue.IssueId = nextId 68 69 + res, err := tx.Exec(` 70 insert into issues (repo_at, owner_did, issue_id, title, body) 71 values (?, ?, ?, ?, ?) 72 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 73 if err != nil { 74 return err 75 } 76 + 77 + lastID, err := res.LastInsertId() 78 + if err != nil { 79 + return err 80 + } 81 + issue.ID = lastID 82 83 if err := tx.Commit(); err != nil { 84 return err ··· 96 var issueAt string 97 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 98 return issueAt, err 99 } 100 101 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 115 ` 116 with numbered_issue as ( 117 select 118 + i.id, 119 i.owner_did, 120 i.issue_id, 121 i.created, ··· 134 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 135 ) 136 select 137 + id, 138 owner_did, 139 issue_id, 140 created, ··· 156 var issue Issue 157 var createdAt string 158 var metadata IssueMetadata 159 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 if err != nil { 161 return nil, err 162 } ··· 185 186 rows, err := e.Query( 187 `select 188 + i.id, 189 i.owner_did, 190 i.repo_at, 191 i.issue_id, ··· 217 var issueCreatedAt, repoCreatedAt string 218 var repo Repo 219 err := rows.Scan( 220 + &issue.ID, 221 &issue.OwnerDid, 222 &issue.RepoAt, 223 &issue.IssueId, ··· 262 } 263 264 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 = ?` 266 row := e.QueryRow(query, repoAt, issueId) 267 268 var issue Issue 269 var createdAt string 270 + err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 if err != nil { 272 return nil, err 273 } ··· 282 } 283 284 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 285 + query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 286 row := e.QueryRow(query, repoAt, issueId) 287 288 var issue Issue 289 var createdAt string 290 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 291 if err != nil { 292 return nil, nil, err 293 }
+93
appview/db/language.go
···
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type RepoLanguage struct { 11 + Id int64 12 + RepoAt syntax.ATURI 13 + Ref string 14 + IsDefaultRef bool 15 + Language string 16 + Bytes int64 17 + } 18 + 19 + func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) { 20 + var conditions []string 21 + var args []any 22 + for _, filter := range filters { 23 + conditions = append(conditions, filter.Condition()) 24 + args = append(args, filter.Arg()...) 25 + } 26 + 27 + whereClause := "" 28 + if conditions != nil { 29 + whereClause = " where " + strings.Join(conditions, " and ") 30 + } 31 + 32 + query := fmt.Sprintf( 33 + `select id, repo_at, ref, is_default_ref, language, bytes from repo_languages %s`, 34 + whereClause, 35 + ) 36 + rows, err := e.Query(query, args...) 37 + 38 + if err != nil { 39 + return nil, fmt.Errorf("failed to execute query: %w ", err) 40 + } 41 + 42 + var langs []RepoLanguage 43 + for rows.Next() { 44 + var rl RepoLanguage 45 + var isDefaultRef int 46 + 47 + err := rows.Scan( 48 + &rl.Id, 49 + &rl.RepoAt, 50 + &rl.Ref, 51 + &isDefaultRef, 52 + &rl.Language, 53 + &rl.Bytes, 54 + ) 55 + if err != nil { 56 + return nil, fmt.Errorf("failed to scan: %w ", err) 57 + } 58 + 59 + if isDefaultRef != 0 { 60 + rl.IsDefaultRef = true 61 + } 62 + 63 + langs = append(langs, rl) 64 + } 65 + if err = rows.Err(); err != nil { 66 + return nil, fmt.Errorf("failed to scan rows: %w ", err) 67 + } 68 + 69 + return langs, nil 70 + } 71 + 72 + func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 73 + stmt, err := e.Prepare( 74 + "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 + ) 76 + if err != nil { 77 + return err 78 + } 79 + 80 + for _, l := range langs { 81 + isDefaultRef := 0 82 + if l.IsDefaultRef { 83 + isDefaultRef = 1 84 + } 85 + 86 + _, err := stmt.Exec(l.RepoAt, l.Ref, isDefaultRef, l.Language, l.Bytes) 87 + if err != nil { 88 + return err 89 + } 90 + } 91 + 92 + return nil 93 + }
+108
appview/db/profile.go
··· 348 return tx.Commit() 349 } 350 351 func GetProfile(e Execer, did string) (*Profile, error) { 352 var profile Profile 353 profile.Did = did
··· 348 return tx.Commit() 349 } 350 351 + func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 + var conditions []string 353 + var args []any 354 + for _, filter := range filters { 355 + conditions = append(conditions, filter.Condition()) 356 + args = append(args, filter.Arg()...) 357 + } 358 + 359 + whereClause := "" 360 + if conditions != nil { 361 + whereClause = " where " + strings.Join(conditions, " and ") 362 + } 363 + 364 + profilesQuery := fmt.Sprintf( 365 + `select 366 + id, 367 + did, 368 + description, 369 + include_bluesky, 370 + location 371 + from 372 + profile 373 + %s`, 374 + whereClause, 375 + ) 376 + rows, err := e.Query(profilesQuery, args...) 377 + if err != nil { 378 + return nil, err 379 + } 380 + 381 + profileMap := make(map[string]*Profile) 382 + for rows.Next() { 383 + var profile Profile 384 + var includeBluesky int 385 + 386 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 387 + if err != nil { 388 + return nil, err 389 + } 390 + 391 + if includeBluesky != 0 { 392 + profile.IncludeBluesky = true 393 + } 394 + 395 + profileMap[profile.Did] = &profile 396 + } 397 + if err = rows.Err(); err != nil { 398 + return nil, err 399 + } 400 + 401 + // populate profile links 402 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 403 + args = make([]any, len(profileMap)) 404 + i := 0 405 + for did := range profileMap { 406 + args[i] = did 407 + i++ 408 + } 409 + 410 + linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 411 + rows, err = e.Query(linksQuery, args...) 412 + if err != nil { 413 + return nil, err 414 + } 415 + idxs := make(map[string]int) 416 + for did := range profileMap { 417 + idxs[did] = 0 418 + } 419 + for rows.Next() { 420 + var link, did string 421 + if err = rows.Scan(&link, &did); err != nil { 422 + return nil, err 423 + } 424 + 425 + idx := idxs[did] 426 + profileMap[did].Links[idx] = link 427 + idxs[did] = idx + 1 428 + } 429 + 430 + pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause) 431 + rows, err = e.Query(pinsQuery, args...) 432 + if err != nil { 433 + return nil, err 434 + } 435 + idxs = make(map[string]int) 436 + for did := range profileMap { 437 + idxs[did] = 0 438 + } 439 + for rows.Next() { 440 + var link syntax.ATURI 441 + var did string 442 + if err = rows.Scan(&link, &did); err != nil { 443 + return nil, err 444 + } 445 + 446 + idx := idxs[did] 447 + profileMap[did].PinnedRepos[idx] = link 448 + idxs[did] = idx + 1 449 + } 450 + 451 + var profiles []Profile 452 + for _, p := range profileMap { 453 + profiles = append(profiles, *p) 454 + } 455 + 456 + return profiles, nil 457 + } 458 + 459 func GetProfile(e Execer, did string) (*Profile, error) { 460 var profile Profile 461 profile.Did = did
+141
appview/db/reaction.go
···
··· 1 + package db 2 + 3 + import ( 4 + "log" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type ReactionKind string 11 + 12 + const ( 13 + Like ReactionKind = "๐Ÿ‘" 14 + Unlike = "๐Ÿ‘Ž" 15 + Laugh = "๐Ÿ˜†" 16 + Celebration = "๐ŸŽ‰" 17 + Confused = "๐Ÿซค" 18 + Heart = "โค๏ธ" 19 + Rocket = "๐Ÿš€" 20 + Eyes = "๐Ÿ‘€" 21 + ) 22 + 23 + func (rk ReactionKind) String() string { 24 + return string(rk) 25 + } 26 + 27 + var OrderedReactionKinds = []ReactionKind{ 28 + Like, 29 + Unlike, 30 + Laugh, 31 + Celebration, 32 + Confused, 33 + Heart, 34 + Rocket, 35 + Eyes, 36 + } 37 + 38 + func ParseReactionKind(raw string) (ReactionKind, bool) { 39 + k, ok := (map[string]ReactionKind{ 40 + "๐Ÿ‘": Like, 41 + "๐Ÿ‘Ž": Unlike, 42 + "๐Ÿ˜†": Laugh, 43 + "๐ŸŽ‰": Celebration, 44 + "๐Ÿซค": Confused, 45 + "โค๏ธ": Heart, 46 + "๐Ÿš€": Rocket, 47 + "๐Ÿ‘€": Eyes, 48 + })[raw] 49 + return k, ok 50 + } 51 + 52 + type Reaction struct { 53 + ReactedByDid string 54 + ThreadAt syntax.ATURI 55 + Created time.Time 56 + Rkey string 57 + Kind ReactionKind 58 + } 59 + 60 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 61 + query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 + _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 + return err 64 + } 65 + 66 + // Get a reaction record 67 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 68 + query := ` 69 + select reacted_by_did, thread_at, created, rkey 70 + from reactions 71 + where reacted_by_did = ? and thread_at = ? and kind = ?` 72 + row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 + 74 + var reaction Reaction 75 + var created string 76 + err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + createdAtTime, err := time.Parse(time.RFC3339, created) 82 + if err != nil { 83 + log.Println("unable to determine followed at time") 84 + reaction.Created = time.Now() 85 + } else { 86 + reaction.Created = createdAtTime 87 + } 88 + 89 + return &reaction, nil 90 + } 91 + 92 + // Remove a reaction 93 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 94 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 + return err 96 + } 97 + 98 + // Remove a reaction 99 + func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { 100 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) 101 + return err 102 + } 103 + 104 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 105 + count := 0 106 + err := e.QueryRow( 107 + `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) 108 + if err != nil { 109 + return 0, err 110 + } 111 + return count, nil 112 + } 113 + 114 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 + countMap := map[ReactionKind]int{} 116 + for _, kind := range OrderedReactionKinds { 117 + count, err := GetReactionCount(e, threadAt, kind) 118 + if err != nil { 119 + return map[ReactionKind]int{}, nil 120 + } 121 + countMap[kind] = count 122 + } 123 + return countMap, nil 124 + } 125 + 126 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 127 + if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 + return false 129 + } else { 130 + return true 131 + } 132 + } 133 + 134 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 + statusMap := map[ReactionKind]bool{} 136 + for _, kind := range OrderedReactionKinds { 137 + count := GetReactionStatus(e, userDid, threadAt, kind) 138 + statusMap[kind] = count 139 + } 140 + return statusMap 141 + }
+5 -4
appview/db/registration.go
··· 10 ) 11 12 type Registration struct { 13 Domain string 14 ByDid string 15 Created *time.Time ··· 36 var registrations []Registration 37 38 rows, err := e.Query(` 39 - select domain, did, created, registered from registrations 40 where did = ? 41 `, did) 42 if err != nil { ··· 47 var createdAt *string 48 var registeredAt *string 49 var registration Registration 50 - err = rows.Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 51 52 if err != nil { 53 log.Println(err) ··· 75 var registration Registration 76 77 err := e.QueryRow(` 78 - select domain, did, created, registered from registrations 79 where domain = ? 80 - `, domain).Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 82 if err != nil { 83 if err == sql.ErrNoRows {
··· 10 ) 11 12 type Registration struct { 13 + Id int64 14 Domain string 15 ByDid string 16 Created *time.Time ··· 37 var registrations []Registration 38 39 rows, err := e.Query(` 40 + select id, domain, did, created, registered from registrations 41 where did = ? 42 `, did) 43 if err != nil { ··· 48 var createdAt *string 49 var registeredAt *string 50 var registration Registration 51 + err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 52 53 if err != nil { 54 log.Println(err) ··· 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) 82 83 if err != nil { 84 if err == sql.ErrNoRows {
+68 -70
appview/db/repos.go
··· 3 import ( 4 "database/sql" 5 "fmt" 6 "strings" 7 "time" 8 ··· 71 return repos, nil 72 } 73 74 - func GetRepos(e Execer, filters ...filter) ([]Repo, error) { 75 - repoMap := make(map[syntax.ATURI]Repo) 76 77 var conditions []string 78 var args []any ··· 86 whereClause = " where " + strings.Join(conditions, " and ") 87 } 88 89 repoQuery := fmt.Sprintf( 90 `select 91 did, ··· 98 spindle 99 from 100 repos r 101 %s`, 102 whereClause, 103 ) 104 rows, err := e.Query(repoQuery, args...) 105 ··· 139 repo.Spindle = spindle.String 140 } 141 142 - repoMap[repo.RepoAt()] = repo 143 } 144 145 if err = rows.Err(); err != nil { ··· 148 149 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 150 args = make([]any, len(repoMap)) 151 for _, r := range repoMap { 152 - args = append(args, r.RepoAt()) 153 } 154 155 starCountQuery := fmt.Sprintf( ··· 168 var repoat string 169 var count int 170 if err := rows.Scan(&repoat, &count); err != nil { 171 continue 172 } 173 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 196 var repoat string 197 var open, closed int 198 if err := rows.Scan(&repoat, &open, &closed); err != nil { 199 continue 200 } 201 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 236 var repoat string 237 var open, merged, closed, deleted int 238 if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 239 continue 240 } 241 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 251 252 var repos []Repo 253 for _, r := range repoMap { 254 - repos = append(repos, r) 255 } 256 257 return repos, nil 258 } 259 ··· 488 return &repo, nil 489 } 490 491 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 492 - _, err := e.Exec( 493 - `insert into collaborators (did, repo) 494 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 495 - collaborator, repoOwnerDid, repoName, repoKnot) 496 - return err 497 - } 498 - 499 func UpdateDescription(e Execer, repoAt, newDescription string) error { 500 _, err := e.Exec( 501 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) ··· 508 return err 509 } 510 511 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 512 - var repos []Repo 513 - 514 - rows, err := e.Query( 515 - `select 516 - r.did, r.name, r.knot, r.rkey, r.description, r.created, count(s.id) as star_count 517 - from 518 - repos r 519 - join 520 - collaborators c on r.id = c.repo 521 - left join 522 - stars s on r.at_uri = s.repo_at 523 - where 524 - c.did = ? 525 - group by 526 - r.id;`, collaborator) 527 - if err != nil { 528 - return nil, err 529 - } 530 - defer rows.Close() 531 - 532 - for rows.Next() { 533 - var repo Repo 534 - var repoStats RepoStats 535 - var createdAt string 536 - var nullableDescription sql.NullString 537 - 538 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount) 539 - if err != nil { 540 - return nil, err 541 - } 542 - 543 - if nullableDescription.Valid { 544 - repo.Description = nullableDescription.String 545 - } else { 546 - repo.Description = "" 547 - } 548 - 549 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 550 - if err != nil { 551 - repo.Created = time.Now() 552 - } else { 553 - repo.Created = createdAtTime 554 - } 555 - 556 - repo.RepoStats = &repoStats 557 - 558 - repos = append(repos, repo) 559 - } 560 - 561 - if err := rows.Err(); err != nil { 562 - return nil, err 563 - } 564 - 565 - return repos, nil 566 - } 567 - 568 type RepoStats struct { 569 StarCount int 570 IssueCount IssueCount 571 PullCount PullCount
··· 3 import ( 4 "database/sql" 5 "fmt" 6 + "log" 7 + "slices" 8 "strings" 9 "time" 10 ··· 73 return repos, nil 74 } 75 76 + func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 77 + repoMap := make(map[syntax.ATURI]*Repo) 78 79 var conditions []string 80 var args []any ··· 88 whereClause = " where " + strings.Join(conditions, " and ") 89 } 90 91 + limitClause := "" 92 + if limit != 0 { 93 + limitClause = fmt.Sprintf(" limit %d", limit) 94 + } 95 + 96 repoQuery := fmt.Sprintf( 97 `select 98 did, ··· 105 spindle 106 from 107 repos r 108 + %s 109 + order by created desc 110 %s`, 111 whereClause, 112 + limitClause, 113 ) 114 rows, err := e.Query(repoQuery, args...) 115 ··· 149 repo.Spindle = spindle.String 150 } 151 152 + repo.RepoStats = &RepoStats{} 153 + repoMap[repo.RepoAt()] = &repo 154 } 155 156 if err = rows.Err(); err != nil { ··· 159 160 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 161 args = make([]any, len(repoMap)) 162 + 163 + i := 0 164 for _, r := range repoMap { 165 + args[i] = r.RepoAt() 166 + i++ 167 + } 168 + 169 + languageQuery := fmt.Sprintf( 170 + ` 171 + select 172 + repo_at, language 173 + from 174 + repo_languages r1 175 + where 176 + repo_at IN (%s) 177 + and is_default_ref = 1 178 + and id = ( 179 + select id 180 + from repo_languages r2 181 + where r2.repo_at = r1.repo_at 182 + and r2.is_default_ref = 1 183 + order by bytes desc 184 + limit 1 185 + ); 186 + `, 187 + inClause, 188 + ) 189 + rows, err = e.Query(languageQuery, args...) 190 + if err != nil { 191 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 192 + } 193 + for rows.Next() { 194 + var repoat, lang string 195 + if err := rows.Scan(&repoat, &lang); err != nil { 196 + log.Println("err", "err", err) 197 + continue 198 + } 199 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 200 + r.RepoStats.Language = lang 201 + } 202 + } 203 + if err = rows.Err(); err != nil { 204 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 205 } 206 207 starCountQuery := fmt.Sprintf( ··· 220 var repoat string 221 var count int 222 if err := rows.Scan(&repoat, &count); err != nil { 223 + log.Println("err", "err", err) 224 continue 225 } 226 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 249 var repoat string 250 var open, closed int 251 if err := rows.Scan(&repoat, &open, &closed); err != nil { 252 + log.Println("err", "err", err) 253 continue 254 } 255 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 290 var repoat string 291 var open, merged, closed, deleted int 292 if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 293 + log.Println("err", "err", err) 294 continue 295 } 296 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 306 307 var repos []Repo 308 for _, r := range repoMap { 309 + repos = append(repos, *r) 310 } 311 312 + slices.SortFunc(repos, func(a, b Repo) int { 313 + if a.Created.After(b.Created) { 314 + return 1 315 + } 316 + return -1 317 + }) 318 + 319 return repos, nil 320 } 321 ··· 550 return &repo, nil 551 } 552 553 func UpdateDescription(e Execer, repoAt, newDescription string) error { 554 _, err := e.Exec( 555 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) ··· 562 return err 563 } 564 565 type RepoStats struct { 566 + Language string 567 StarCount int 568 IssueCount IssueCount 569 PullCount PullCount
+29
appview/db/signup.go
···
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + } 11 + 12 + func AddInflightSignup(e Execer, signup InflightSignup) error { 13 + query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 + _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 + return err 16 + } 17 + 18 + func DeleteInflightSignup(e Execer, email string) error { 19 + query := `delete from signups_inflight where email = ?` 20 + _, err := e.Exec(query, email) 21 + return err 22 + } 23 + 24 + func GetEmailForCode(e Execer, inviteCode string) (string, error) { 25 + query := `select email from signups_inflight where invite_code = ?` 26 + var email string 27 + err := e.QueryRow(query, inviteCode).Scan(&email) 28 + return email, err 29 + }
+92 -2
appview/db/star.go
··· 1 package db 2 3 import ( 4 "log" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 31 return nil 32 } 33 34 - func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error { 35 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 36 - _, err := e.Exec(query, starredByDid, repoAt, rkey) 37 return err 38 } 39 ··· 91 } else { 92 return true 93 } 94 } 95 96 func GetAllStars(e Execer, limit int) ([]Star, error) {
··· 1 package db 2 3 import ( 4 + "fmt" 5 "log" 6 + "strings" 7 "time" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 33 return nil 34 } 35 36 + func AddStar(e Execer, star *Star) error { 37 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 38 + _, err := e.Exec( 39 + query, 40 + star.StarredByDid, 41 + star.RepoAt.String(), 42 + star.Rkey, 43 + ) 44 return err 45 } 46 ··· 98 } else { 99 return true 100 } 101 + } 102 + 103 + func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 104 + var conditions []string 105 + var args []any 106 + for _, filter := range filters { 107 + conditions = append(conditions, filter.Condition()) 108 + args = append(args, filter.Arg()...) 109 + } 110 + 111 + whereClause := "" 112 + if conditions != nil { 113 + whereClause = " where " + strings.Join(conditions, " and ") 114 + } 115 + 116 + limitClause := "" 117 + if limit != 0 { 118 + limitClause = fmt.Sprintf(" limit %d", limit) 119 + } 120 + 121 + repoQuery := fmt.Sprintf( 122 + `select starred_by_did, repo_at, created, rkey 123 + from stars 124 + %s 125 + order by created desc 126 + %s`, 127 + whereClause, 128 + limitClause, 129 + ) 130 + rows, err := e.Query(repoQuery, args...) 131 + if err != nil { 132 + return nil, err 133 + } 134 + 135 + starMap := make(map[string][]Star) 136 + for rows.Next() { 137 + var star Star 138 + var created string 139 + err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 140 + if err != nil { 141 + return nil, err 142 + } 143 + 144 + star.Created = time.Now() 145 + if t, err := time.Parse(time.RFC3339, created); err == nil { 146 + star.Created = t 147 + } 148 + 149 + repoAt := string(star.RepoAt) 150 + starMap[repoAt] = append(starMap[repoAt], star) 151 + } 152 + 153 + // populate *Repo in each star 154 + args = make([]any, len(starMap)) 155 + i := 0 156 + for r := range starMap { 157 + args[i] = r 158 + i++ 159 + } 160 + 161 + if len(args) == 0 { 162 + return nil, nil 163 + } 164 + 165 + repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 166 + if err != nil { 167 + return nil, err 168 + } 169 + 170 + for _, r := range repos { 171 + if stars, ok := starMap[string(r.RepoAt())]; ok { 172 + for i := range stars { 173 + stars[i].Repo = &r 174 + } 175 + } 176 + } 177 + 178 + var stars []Star 179 + for _, s := range starMap { 180 + stars = append(stars, s...) 181 + } 182 + 183 + return stars, nil 184 } 185 186 func GetAllStars(e Execer, limit int) ([]Star, error) {
+136 -27
appview/db/timeline.go
··· 14 15 // optional: populate only if Repo is a fork 16 Source *Repo 17 } 18 19 // TODO: this gathers heterogenous events from different sources and aggregates 20 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 21 func MakeTimeline(e Execer) ([]TimelineEvent, error) { 22 var events []TimelineEvent 23 - limit := 50 24 25 - repos, err := GetAllRepos(e, limit) 26 if err != nil { 27 return nil, err 28 } 29 30 - follows, err := GetAllFollows(e, limit) 31 if err != nil { 32 return nil, err 33 } 34 35 - stars, err := GetAllStars(e, limit) 36 if err != nil { 37 return nil, err 38 } 39 40 - for _, repo := range repos { 41 - var sourceRepo *Repo 42 - if repo.Source != "" { 43 - sourceRepo, err = GetRepoByAtUri(e, repo.Source) 44 - if err != nil { 45 - return nil, err 46 } 47 } 48 49 events = append(events, TimelineEvent{ 50 - Repo: &repo, 51 - EventAt: repo.Created, 52 - Source: sourceRepo, 53 }) 54 } 55 56 - for _, follow := range follows { 57 events = append(events, TimelineEvent{ 58 - Follow: &follow, 59 - EventAt: follow.FollowedAt, 60 }) 61 } 62 63 - for _, star := range stars { 64 - events = append(events, TimelineEvent{ 65 - Star: &star, 66 - EventAt: star.Created, 67 - }) 68 } 69 70 - sort.Slice(events, func(i, j int) bool { 71 - return events[i].EventAt.After(events[j].EventAt) 72 - }) 73 74 - // Limit the slice to 100 events 75 - if len(events) > limit { 76 - events = events[:limit] 77 } 78 79 return events, nil
··· 14 15 // optional: populate only if Repo is a fork 16 Source *Repo 17 + 18 + // optional: populate only if event is Follow 19 + *Profile 20 + *FollowStats 21 } 22 + 23 + type FollowStats struct { 24 + Followers int 25 + Following int 26 + } 27 + 28 + const Limit = 50 29 30 // TODO: this gathers heterogenous events from different sources and aggregates 31 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 32 func MakeTimeline(e Execer) ([]TimelineEvent, error) { 33 var events []TimelineEvent 34 35 + repos, err := getTimelineRepos(e) 36 if err != nil { 37 return nil, err 38 } 39 40 + stars, err := getTimelineStars(e) 41 if err != nil { 42 return nil, err 43 } 44 45 + follows, err := getTimelineFollows(e) 46 if err != nil { 47 return nil, err 48 } 49 50 + events = append(events, repos...) 51 + events = append(events, stars...) 52 + events = append(events, follows...) 53 + 54 + sort.Slice(events, func(i, j int) bool { 55 + return events[i].EventAt.After(events[j].EventAt) 56 + }) 57 + 58 + // Limit the slice to 100 events 59 + if len(events) > Limit { 60 + events = events[:Limit] 61 + } 62 + 63 + return events, nil 64 + } 65 + 66 + func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 67 + repos, err := GetRepos(e, Limit) 68 + if err != nil { 69 + return nil, err 70 + } 71 + 72 + // fetch all source repos 73 + var args []string 74 + for _, r := range repos { 75 + if r.Source != "" { 76 + args = append(args, r.Source) 77 + } 78 + } 79 + 80 + var origRepos []Repo 81 + if args != nil { 82 + origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 83 + } 84 + if err != nil { 85 + return nil, err 86 + } 87 + 88 + uriToRepo := make(map[string]Repo) 89 + for _, r := range origRepos { 90 + uriToRepo[r.RepoAt().String()] = r 91 + } 92 + 93 + var events []TimelineEvent 94 + for _, r := range repos { 95 + var source *Repo 96 + if r.Source != "" { 97 + if origRepo, ok := uriToRepo[r.Source]; ok { 98 + source = &origRepo 99 } 100 } 101 102 events = append(events, TimelineEvent{ 103 + Repo: &r, 104 + EventAt: r.Created, 105 + Source: source, 106 }) 107 } 108 109 + return events, nil 110 + } 111 + 112 + func getTimelineStars(e Execer) ([]TimelineEvent, error) { 113 + stars, err := GetStars(e, Limit) 114 + if err != nil { 115 + return nil, err 116 + } 117 + 118 + // filter star records without a repo 119 + n := 0 120 + for _, s := range stars { 121 + if s.Repo != nil { 122 + stars[n] = s 123 + n++ 124 + } 125 + } 126 + stars = stars[:n] 127 + 128 + var events []TimelineEvent 129 + for _, s := range stars { 130 events = append(events, TimelineEvent{ 131 + Star: &s, 132 + EventAt: s.Created, 133 }) 134 } 135 136 + return events, nil 137 + } 138 + 139 + func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 + follows, err := GetAllFollows(e, Limit) 141 + if err != nil { 142 + return nil, err 143 + } 144 + 145 + var subjects []string 146 + for _, f := range follows { 147 + subjects = append(subjects, f.SubjectDid) 148 + } 149 + 150 + if subjects == nil { 151 + return nil, nil 152 + } 153 + 154 + profileMap := make(map[string]Profile) 155 + profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 + if err != nil { 157 + return nil, err 158 + } 159 + for _, p := range profiles { 160 + profileMap[p.Did] = p 161 } 162 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 + } 173 + } 174 175 + var events []TimelineEvent 176 + for _, f := range follows { 177 + profile, _ := profileMap[f.SubjectDid] 178 + followStatMap, _ := followStatMap[f.SubjectDid] 179 + 180 + events = append(events, TimelineEvent{ 181 + Follow: &f, 182 + Profile: &profile, 183 + FollowStats: &followStatMap, 184 + EventAt: f.FollowedAt, 185 + }) 186 } 187 188 return events, nil
+53
appview/dns/cloudflare.go
···
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/cloudflare/cloudflare-go" 8 + "tangled.sh/tangled.sh/core/appview/config" 9 + ) 10 + 11 + type Record struct { 12 + Type string 13 + Name string 14 + Content string 15 + TTL int 16 + Proxied bool 17 + } 18 + 19 + type Cloudflare struct { 20 + api *cloudflare.API 21 + zone string 22 + } 23 + 24 + func NewCloudflare(c *config.Config) (*Cloudflare, error) { 25 + apiToken := c.Cloudflare.ApiToken 26 + api, err := cloudflare.NewWithAPIToken(apiToken) 27 + if err != nil { 28 + return nil, err 29 + } 30 + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 + } 32 + 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 + Type: record.Type, 36 + Name: record.Name, 37 + Content: record.Content, 38 + TTL: record.TTL, 39 + Proxied: &record.Proxied, 40 + }) 41 + if err != nil { 42 + return fmt.Errorf("failed to create DNS record: %w", err) 43 + } 44 + return nil 45 + } 46 + 47 + func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { 48 + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) 49 + if err != nil { 50 + return fmt.Errorf("failed to delete DNS record: %w", err) 51 + } 52 + return nil 53 + }
-113
appview/idresolver/resolver.go
··· 1 - package idresolver 2 - 3 - import ( 4 - "context" 5 - "net" 6 - "net/http" 7 - "sync" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "github.com/carlmjohnson/versioninfo" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 - ) 16 - 17 - type Resolver struct { 18 - directory identity.Directory 19 - } 20 - 21 - func BaseDirectory() identity.Directory { 22 - base := identity.BaseDirectory{ 23 - PLCURL: identity.DefaultPLCURL, 24 - HTTPClient: http.Client{ 25 - Timeout: time.Second * 10, 26 - Transport: &http.Transport{ 27 - // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 28 - IdleConnTimeout: time.Millisecond * 1000, 29 - MaxIdleConns: 100, 30 - }, 31 - }, 32 - Resolver: net.Resolver{ 33 - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 34 - d := net.Dialer{Timeout: time.Second * 3} 35 - return d.DialContext(ctx, network, address) 36 - }, 37 - }, 38 - TryAuthoritativeDNS: true, 39 - // primary Bluesky PDS instance only supports HTTP resolution method 40 - SkipDNSDomainSuffixes: []string{".bsky.social"}, 41 - UserAgent: "indigo-identity/" + versioninfo.Short(), 42 - } 43 - return &base 44 - } 45 - 46 - func RedisDirectory(url string) (identity.Directory, error) { 47 - hitTTL := time.Hour * 24 48 - errTTL := time.Second * 30 49 - invalidHandleTTL := time.Minute * 5 50 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 51 - } 52 - 53 - func DefaultResolver() *Resolver { 54 - return &Resolver{ 55 - directory: identity.DefaultDirectory(), 56 - } 57 - } 58 - 59 - func RedisResolver(config config.RedisConfig) (*Resolver, error) { 60 - directory, err := RedisDirectory(config.ToURL()) 61 - if err != nil { 62 - return nil, err 63 - } 64 - return &Resolver{ 65 - directory: directory, 66 - }, nil 67 - } 68 - 69 - func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 70 - id, err := syntax.ParseAtIdentifier(arg) 71 - if err != nil { 72 - return nil, err 73 - } 74 - 75 - return r.directory.Lookup(ctx, *id) 76 - } 77 - 78 - func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 79 - results := make([]*identity.Identity, len(idents)) 80 - var wg sync.WaitGroup 81 - 82 - done := make(chan struct{}) 83 - defer close(done) 84 - 85 - for idx, ident := range idents { 86 - wg.Add(1) 87 - go func(index int, id string) { 88 - defer wg.Done() 89 - 90 - select { 91 - case <-ctx.Done(): 92 - results[index] = nil 93 - case <-done: 94 - results[index] = nil 95 - default: 96 - identity, _ := r.ResolveIdent(ctx, id) 97 - results[index] = identity 98 - } 99 - }(idx, ident) 100 - } 101 - 102 - wg.Wait() 103 - return results 104 - } 105 - 106 - func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 107 - id, err := syntax.ParseAtIdentifier(arg) 108 - if err != nil { 109 - return err 110 - } 111 - 112 - return r.directory.Purge(ctx, *id) 113 - }
···
+26 -6
appview/ingester.go
··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 "tangled.sh/tangled.sh/core/appview/spindleverify" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) 21 ··· 100 l.Error("invalid record", "err", err) 101 return err 102 } 103 - err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey) 104 case models.CommitOperationDelete: 105 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 106 } ··· 129 return err 130 } 131 132 - subjectDid := record.Subject 133 - err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey) 134 case models.CommitOperationDelete: 135 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 136 } ··· 492 if err != nil || len(spindles) != 1 { 493 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 494 } 495 496 tx, err := ddb.Begin() 497 if err != nil { ··· 502 i.Enforcer.E.LoadPolicy() 503 }() 504 505 - err = db.DeleteSpindle( 506 tx, 507 db.FilterEq("owner", did), 508 db.FilterEq("instance", instance), ··· 511 return err 512 } 513 514 - err = i.Enforcer.RemoveSpindle(instance) 515 if err != nil { 516 return err 517 } 518 519 err = tx.Commit()
··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) 21 ··· 100 l.Error("invalid record", "err", err) 101 return err 102 } 103 + err = db.AddStar(i.Db, &db.Star{ 104 + StarredByDid: did, 105 + RepoAt: subjectUri, 106 + Rkey: e.Commit.RKey, 107 + }) 108 case models.CommitOperationDelete: 109 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 110 } ··· 133 return err 134 } 135 136 + err = db.AddFollow(i.Db, &db.Follow{ 137 + UserDid: did, 138 + SubjectDid: record.Subject, 139 + Rkey: e.Commit.RKey, 140 + }) 141 case models.CommitOperationDelete: 142 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 143 } ··· 499 if err != nil || len(spindles) != 1 { 500 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 501 } 502 + spindle := spindles[0] 503 504 tx, err := ddb.Begin() 505 if err != nil { ··· 510 i.Enforcer.E.LoadPolicy() 511 }() 512 513 + // remove spindle members first 514 + err = db.RemoveSpindleMember( 515 tx, 516 db.FilterEq("owner", did), 517 db.FilterEq("instance", instance), ··· 520 return err 521 } 522 523 + err = db.DeleteSpindle( 524 + tx, 525 + db.FilterEq("owner", did), 526 + db.FilterEq("instance", instance), 527 + ) 528 if err != nil { 529 return err 530 + } 531 + 532 + if spindle.Verified != nil { 533 + err = i.Enforcer.RemoveSpindle(instance) 534 + if err != nil { 535 + return err 536 + } 537 } 538 539 err = tx.Commit()
+32 -31
appview/issues/issues.go
··· 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/atproto/data" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "github.com/go-chi/chi/v5" 16 - "github.com/posthog/posthog-go" 17 18 "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/appview" 20 "tangled.sh/tangled.sh/core/appview/config" 21 "tangled.sh/tangled.sh/core/appview/db" 22 - "tangled.sh/tangled.sh/core/appview/idresolver" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 ) 28 29 type Issues struct { ··· 33 idResolver *idresolver.Resolver 34 db *db.DB 35 config *config.Config 36 - posthog posthog.Client 37 } 38 39 func New( ··· 43 idResolver *idresolver.Resolver, 44 db *db.DB, 45 config *config.Config, 46 - posthog posthog.Client, 47 ) *Issues { 48 return &Issues{ 49 oauth: oauth, ··· 52 idResolver: idResolver, 53 db: db, 54 config: config, 55 - posthog: posthog, 56 } 57 } 58 ··· 79 return 80 } 81 82 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 83 if err != nil { 84 log.Println("failed to resolve issue owner", err) ··· 106 107 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 DidHandleMap: didHandleMap, 109 }) 110 111 } ··· 155 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 Collection: tangled.RepoIssueStateNSID, 157 Repo: user.Did, 158 - Rkey: appview.TID(), 159 Record: &lexutil.LexiconTypeDecoder{ 160 Val: &tangled.RepoIssueState{ 161 Issue: issue.IssueAt, ··· 259 } 260 261 commentId := mathrand.IntN(1000000) 262 - rkey := appview.TID() 263 264 err := db.NewIssueComment(rp.db, &db.Comment{ 265 OwnerDid: user.Did, ··· 687 return 688 } 689 690 - err = db.NewIssue(tx, &db.Issue{ 691 RepoAt: f.RepoAt, 692 Title: title, 693 Body: body, 694 OwnerDid: user.Did, 695 - }) 696 - if err != nil { 697 - log.Println("failed to create issue", err) 698 - rp.pages.Notice(w, "issues", "Failed to create issue.") 699 - return 700 } 701 - 702 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 703 if err != nil { 704 - log.Println("failed to get issue id", err) 705 rp.pages.Notice(w, "issues", "Failed to create issue.") 706 return 707 } ··· 716 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 717 Collection: tangled.RepoIssueNSID, 718 Repo: user.Did, 719 - Rkey: appview.TID(), 720 Record: &lexutil.LexiconTypeDecoder{ 721 Val: &tangled.RepoIssue{ 722 Repo: atUri, 723 Title: title, 724 Body: &body, 725 Owner: user.Did, 726 - IssueId: int64(issueId), 727 }, 728 }, 729 }) ··· 733 return 734 } 735 736 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 737 if err != nil { 738 log.Println("failed to set issue at", err) 739 rp.pages.Notice(w, "issues", "Failed to create issue.") 740 return 741 } 742 743 - if !rp.config.Core.Dev { 744 - err = rp.posthog.Enqueue(posthog.Capture{ 745 - DistinctId: user.Did, 746 - Event: "new_issue", 747 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 748 - }) 749 - if err != nil { 750 - log.Println("failed to enqueue posthog event:", err) 751 - } 752 - } 753 754 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 755 return 756 } 757 }
··· 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/atproto/data" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/config" 20 "tangled.sh/tangled.sh/core/appview/db" 21 + "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 "tangled.sh/tangled.sh/core/appview/pagination" 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/idresolver" 27 + "tangled.sh/tangled.sh/core/tid" 28 ) 29 30 type Issues struct { ··· 34 idResolver *idresolver.Resolver 35 db *db.DB 36 config *config.Config 37 + notifier notify.Notifier 38 } 39 40 func New( ··· 44 idResolver *idresolver.Resolver, 45 db *db.DB, 46 config *config.Config, 47 + notifier notify.Notifier, 48 ) *Issues { 49 return &Issues{ 50 oauth: oauth, ··· 53 idResolver: idResolver, 54 db: db, 55 config: config, 56 + notifier: notifier, 57 } 58 } 59 ··· 80 return 81 } 82 83 + reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 + if err != nil { 85 + log.Println("failed to get issue reactions") 86 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + } 88 + 89 + userReactions := map[db.ReactionKind]bool{} 90 + if user != nil { 91 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 + } 93 + 94 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 95 if err != nil { 96 log.Println("failed to resolve issue owner", err) ··· 118 119 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 120 DidHandleMap: didHandleMap, 121 + 122 + OrderedReactionKinds: db.OrderedReactionKinds, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 125 }) 126 127 } ··· 171 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 Collection: tangled.RepoIssueStateNSID, 173 Repo: user.Did, 174 + Rkey: tid.TID(), 175 Record: &lexutil.LexiconTypeDecoder{ 176 Val: &tangled.RepoIssueState{ 177 Issue: issue.IssueAt, ··· 275 } 276 277 commentId := mathrand.IntN(1000000) 278 + rkey := tid.TID() 279 280 err := db.NewIssueComment(rp.db, &db.Comment{ 281 OwnerDid: user.Did, ··· 703 return 704 } 705 706 + issue := &db.Issue{ 707 RepoAt: f.RepoAt, 708 Title: title, 709 Body: body, 710 OwnerDid: user.Did, 711 } 712 + err = db.NewIssue(tx, issue) 713 if err != nil { 714 + log.Println("failed to create issue", err) 715 rp.pages.Notice(w, "issues", "Failed to create issue.") 716 return 717 } ··· 726 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 Collection: tangled.RepoIssueNSID, 728 Repo: user.Did, 729 + Rkey: tid.TID(), 730 Record: &lexutil.LexiconTypeDecoder{ 731 Val: &tangled.RepoIssue{ 732 Repo: atUri, 733 Title: title, 734 Body: &body, 735 Owner: user.Did, 736 + IssueId: int64(issue.IssueId), 737 }, 738 }, 739 }) ··· 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 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 return 751 } 752 753 + rp.notifier.NewIssue(r.Context(), issue) 754 755 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 756 return 757 } 758 }
+494
appview/knots/knots.go
···
··· 1 + package knots 2 + 3 + import ( 4 + "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "strings" 12 + "time" 13 + 14 + "github.com/go-chi/chi/v5" 15 + "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview/config" 17 + "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/middleware" 19 + "tangled.sh/tangled.sh/core/appview/oauth" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/eventconsumer" 22 + "tangled.sh/tangled.sh/core/idresolver" 23 + "tangled.sh/tangled.sh/core/knotclient" 24 + "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/tid" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 + ) 30 + 31 + type Knots struct { 32 + Db *db.DB 33 + OAuth *oauth.OAuth 34 + Pages *pages.Pages 35 + Config *config.Config 36 + Enforcer *rbac.Enforcer 37 + IdResolver *idresolver.Resolver 38 + Logger *slog.Logger 39 + Knotstream *eventconsumer.Consumer 40 + } 41 + 42 + func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 43 + r := chi.NewRouter() 44 + 45 + r.Use(middleware.AuthMiddleware(k.OAuth)) 46 + 47 + r.Get("/", k.index) 48 + r.Post("/key", k.generateKey) 49 + 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 + }) 60 + 61 + return r 62 + } 63 + 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 + 68 + user := k.OAuth.GetUser(r) 69 + registrations, err := db.RegistrationsByDid(k.Db, user.Did) 70 + if err != nil { 71 + l.Error("failed to get registrations by did", "err", err) 72 + } 73 + 74 + k.Pages.Knots(w, pages.KnotsParams{ 75 + LoggedInUser: user, 76 + Registrations: registrations, 77 + }) 78 + } 79 + 80 + // requires auth 81 + func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 + l := k.Logger.With("handler", "generateKey") 83 + 84 + user := k.OAuth.GetUser(r) 85 + did := user.Did 86 + l = l.With("did", did) 87 + 88 + // check if domain is valid url, and strip extra bits down to just host 89 + domain := r.FormValue("domain") 90 + if domain == "" { 91 + l.Error("empty domain") 92 + http.Error(w, "Invalid form", http.StatusBadRequest) 93 + return 94 + } 95 + l = l.With("domain", domain) 96 + 97 + noticeId := "registration-error" 98 + fail := func() { 99 + k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 100 + } 101 + 102 + key, err := db.GenerateRegistrationKey(k.Db, domain, did) 103 + if err != nil { 104 + l.Error("failed to generate registration key", "err", err) 105 + fail() 106 + return 107 + } 108 + 109 + allRegs, err := db.RegistrationsByDid(k.Db, did) 110 + if err != nil { 111 + l.Error("failed to generate registration key", "err", err) 112 + fail() 113 + return 114 + } 115 + 116 + k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 + Registrations: allRegs, 118 + }) 119 + k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 + Secret: key, 121 + }) 122 + } 123 + 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") 127 + user := k.OAuth.GetUser(r) 128 + 129 + noticeId := "operation-error" 130 + defaultErr := "Failed to initialize knot. Try again later." 131 + fail := func() { 132 + k.Pages.Notice(w, noticeId, defaultErr) 133 + } 134 + 135 + domain := chi.URLParam(r, "domain") 136 + if domain == "" { 137 + http.Error(w, "malformed url", http.StatusBadRequest) 138 + return 139 + } 140 + l = l.With("domain", domain) 141 + 142 + l.Info("checking domain") 143 + 144 + registration, err := db.RegistrationByDomain(k.Db, domain) 145 + if err != nil { 146 + l.Error("failed to get registration for domain", "err", err) 147 + fail() 148 + return 149 + } 150 + if registration.ByDid != user.Did { 151 + l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 + w.WriteHeader(http.StatusUnauthorized) 153 + return 154 + } 155 + 156 + secret, err := db.GetRegistrationKey(k.Db, domain) 157 + if err != nil { 158 + l.Error("failed to get registration key for domain", "err", err) 159 + fail() 160 + return 161 + } 162 + 163 + client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 164 + if err != nil { 165 + l.Error("failed to create knotclient", "err", err) 166 + fail() 167 + return 168 + } 169 + 170 + resp, err := client.Init(user.Did) 171 + 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) 174 + return 175 + } 176 + 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) 180 + return 181 + } 182 + 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) 186 + return 187 + } 188 + 189 + // verify response mac 190 + signature := resp.Header.Get("X-Signature") 191 + signatureBytes, err := hex.DecodeString(signature) 192 + if err != nil { 193 + return 194 + } 195 + 196 + expectedMac := hmac.New(sha256.New, []byte(secret)) 197 + expectedMac.Write([]byte("ok")) 198 + 199 + if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 + k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 + l.Error("signature mismatch", "bytes", signatureBytes) 202 + return 203 + } 204 + 205 + tx, err := k.Db.BeginTx(r.Context(), nil) 206 + if err != nil { 207 + l.Error("failed to start tx", "err", err) 208 + fail() 209 + return 210 + } 211 + defer func() { 212 + tx.Rollback() 213 + err = k.Enforcer.E.LoadPolicy() 214 + if err != nil { 215 + l.Error("rollback failed", "err", err) 216 + } 217 + }() 218 + 219 + // mark as registered 220 + err = db.Register(tx, domain) 221 + if err != nil { 222 + l.Error("failed to register domain", "err", err) 223 + fail() 224 + return 225 + } 226 + 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 233 + } 234 + 235 + // add basic acls for this domain 236 + err = k.Enforcer.AddKnot(domain) 237 + if err != nil { 238 + l.Error("failed to add knot to enforcer", "err", err) 239 + fail() 240 + return 241 + } 242 + 243 + // add this did as owner of this domain 244 + err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 245 + if err != nil { 246 + l.Error("failed to add knot owner to enforcer", "err", err) 247 + fail() 248 + return 249 + } 250 + 251 + err = tx.Commit() 252 + if err != nil { 253 + l.Error("failed to commit changes", "err", err) 254 + fail() 255 + return 256 + } 257 + 258 + err = k.Enforcer.E.SavePolicy() 259 + if err != nil { 260 + l.Error("failed to update ACLs", "err", err) 261 + fail() 262 + return 263 + } 264 + 265 + // add this knot to knotstream 266 + go k.Knotstream.AddSource( 267 + context.Background(), 268 + eventconsumer.NewKnotSource(domain), 269 + ) 270 + 271 + k.Pages.KnotListing(w, pages.KnotListingParams{ 272 + Registration: *reg, 273 + }) 274 + } 275 + 276 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 + l := k.Logger.With("handler", "dashboard") 278 + fail := func() { 279 + w.WriteHeader(http.StatusInternalServerError) 280 + } 281 + 282 + domain := chi.URLParam(r, "domain") 283 + if domain == "" { 284 + http.Error(w, "malformed url", http.StatusBadRequest) 285 + return 286 + } 287 + l = l.With("domain", domain) 288 + 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) 294 + if err != nil { 295 + l.Error("failed to query enforcer", "err", err) 296 + fail() 297 + } 298 + if !ok { 299 + http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 300 + return 301 + } 302 + 303 + reg, err := db.RegistrationByDomain(k.Db, domain) 304 + if err != nil { 305 + l.Error("failed to get registration by domain", "err", err) 306 + fail() 307 + return 308 + } 309 + 310 + var members []string 311 + if reg.Registered != nil { 312 + members, err = k.Enforcer.GetUserByRole("server:member", domain) 313 + if err != nil { 314 + l.Error("failed to get members list", "err", err) 315 + fail() 316 + return 317 + } 318 + } 319 + 320 + repos, err := db.GetRepos( 321 + k.Db, 322 + 0, 323 + db.FilterEq("knot", domain), 324 + db.FilterIn("did", members), 325 + ) 326 + if err != nil { 327 + l.Error("failed to get repos list", "err", err) 328 + fail() 329 + return 330 + } 331 + // convert to map 332 + repoByMember := make(map[string][]db.Repo) 333 + for _, r := range repos { 334 + repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 + } 336 + 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 + } 351 + 352 + k.Pages.Knot(w, pages.KnotParams{ 353 + LoggedInUser: user, 354 + DidHandleMap: didHandleMap, 355 + Registration: reg, 356 + Members: members, 357 + Repos: repoByMember, 358 + IsOwner: true, 359 + }) 360 + } 361 + 362 + // list members of domain, requires auth and requires owner status 363 + func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 + l := k.Logger.With("handler", "members") 365 + 366 + domain := chi.URLParam(r, "domain") 367 + if domain == "" { 368 + http.Error(w, "malformed url", http.StatusBadRequest) 369 + return 370 + } 371 + l = l.With("domain", domain) 372 + 373 + // list all members for this domain 374 + memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 375 + 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) 390 + return 391 + } 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) 398 + return 399 + } 400 + 401 + noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 402 + l = l.With("notice-id", noticeId) 403 + defaultErr := "Failed to add member. Try again later." 404 + fail := func() { 405 + k.Pages.Notice(w, noticeId, defaultErr) 406 + } 407 + 408 + subjectIdentifier := r.FormValue("subject") 409 + if subjectIdentifier == "" { 410 + http.Error(w, "malformed form", http.StatusBadRequest) 411 + return 412 + } 413 + l = l.With("subjectIdentifier", subjectIdentifier) 414 + 415 + subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 416 + if err != nil { 417 + l.Error("failed to resolve identity", "err", err) 418 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 419 + return 420 + } 421 + l = l.With("subjectDid", subjectIdentity.DID) 422 + 423 + l.Info("adding member to knot") 424 + 425 + // announce this relation into the firehose, store into owners' pds 426 + client, err := k.OAuth.AuthorizedClient(r) 427 + if err != nil { 428 + l.Error("failed to create client", "err", err) 429 + fail() 430 + return 431 + } 432 + 433 + currentUser := k.OAuth.GetUser(r) 434 + createdAt := time.Now().Format(time.RFC3339) 435 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 + Collection: tangled.KnotMemberNSID, 437 + Repo: currentUser.Did, 438 + Rkey: tid.TID(), 439 + Record: &lexutil.LexiconTypeDecoder{ 440 + Val: &tangled.KnotMember{ 441 + Subject: subjectIdentity.DID.String(), 442 + Domain: domain, 443 + CreatedAt: createdAt, 444 + }}, 445 + }) 446 + // invalid record 447 + if err != nil { 448 + l.Error("failed to write to PDS", "err", err) 449 + fail() 450 + return 451 + } 452 + l = l.With("at-uri", resp.Uri) 453 + l.Info("wrote record to PDS") 454 + 455 + secret, err := db.GetRegistrationKey(k.Db, domain) 456 + if err != nil { 457 + l.Error("failed to get registration key", "err", err) 458 + fail() 459 + return 460 + } 461 + 462 + ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 463 + if err != nil { 464 + l.Error("failed to create client", "err", err) 465 + fail() 466 + return 467 + } 468 + 469 + ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 470 + if err != nil { 471 + l.Error("failed to reach knotserver", "err", err) 472 + k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 473 + return 474 + } 475 + 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)) 479 + return 480 + } 481 + 482 + err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 483 + if err != nil { 484 + l.Error("failed to add member to enforcer", "err", err) 485 + fail() 486 + return 487 + } 488 + 489 + // success 490 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 491 + } 492 + 493 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 494 + }
+1 -1
appview/middleware/middleware.go
··· 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/pagination" 20 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) 23
··· 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/appview/db" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/pagination" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) 23
+68
appview/notify/merged_notifier.go
···
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.sh/tangled.sh/core/appview/db" 7 + ) 8 + 9 + type mergedNotifier struct { 10 + notifiers []Notifier 11 + } 12 + 13 + func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 + return &mergedNotifier{notifiers} 15 + } 16 + 17 + var _ Notifier = &mergedNotifier{} 18 + 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 20 + for _, notifier := range m.notifiers { 21 + notifier.NewRepo(ctx, repo) 22 + } 23 + } 24 + 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 26 + for _, notifier := range m.notifiers { 27 + notifier.NewStar(ctx, star) 28 + } 29 + } 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 31 + for _, notifier := range m.notifiers { 32 + notifier.DeleteStar(ctx, star) 33 + } 34 + } 35 + 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 37 + for _, notifier := range m.notifiers { 38 + notifier.NewIssue(ctx, issue) 39 + } 40 + } 41 + 42 + func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 43 + for _, notifier := range m.notifiers { 44 + notifier.NewFollow(ctx, follow) 45 + } 46 + } 47 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 48 + for _, notifier := range m.notifiers { 49 + notifier.DeleteFollow(ctx, follow) 50 + } 51 + } 52 + 53 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 54 + for _, notifier := range m.notifiers { 55 + notifier.NewPull(ctx, pull) 56 + } 57 + } 58 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 59 + for _, notifier := range m.notifiers { 60 + notifier.NewPullComment(ctx, comment) 61 + } 62 + } 63 + 64 + func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 65 + for _, notifier := range m.notifiers { 66 + notifier.UpdateProfile(ctx, profile) 67 + } 68 + }
+44
appview/notify/notifier.go
···
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.sh/tangled.sh/core/appview/db" 7 + ) 8 + 9 + type Notifier interface { 10 + NewRepo(ctx context.Context, repo *db.Repo) 11 + 12 + NewStar(ctx context.Context, star *db.Star) 13 + DeleteStar(ctx context.Context, star *db.Star) 14 + 15 + NewIssue(ctx context.Context, issue *db.Issue) 16 + 17 + NewFollow(ctx context.Context, follow *db.Follow) 18 + DeleteFollow(ctx context.Context, follow *db.Follow) 19 + 20 + NewPull(ctx context.Context, pull *db.Pull) 21 + NewPullComment(ctx context.Context, comment *db.PullComment) 22 + 23 + UpdateProfile(ctx context.Context, profile *db.Profile) 24 + } 25 + 26 + // BaseNotifier is a listener that does nothing 27 + type BaseNotifier struct{} 28 + 29 + var _ Notifier = &BaseNotifier{} 30 + 31 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 32 + 33 + func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 34 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 35 + 36 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 37 + 38 + func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 39 + func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 40 + 41 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 42 + func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 43 + 44 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+1 -1
appview/oauth/handler/handler.go
··· 16 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 "tangled.sh/tangled.sh/core/appview/middleware" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 )
··· 16 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 "tangled.sh/tangled.sh/core/appview/middleware" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/oauth/client" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 )
+73
appview/oauth/oauth.go
··· 7 "net/url" 8 "time" 9 10 "github.com/gorilla/sessions" 11 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 204 }) 205 206 return xrpcClient, nil 207 } 208 209 type ClientMetadata struct {
··· 7 "net/url" 8 "time" 9 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 oauth "tangled.sh/icyphox.sh/atproto-oauth" 13 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 205 }) 206 207 return xrpcClient, nil 208 + } 209 + 210 + // use this to create a client to communicate with knots or spindles 211 + // 212 + // this is a higher level abstraction on ServerGetServiceAuth 213 + type ServiceClientOpts struct { 214 + service string 215 + exp int64 216 + lxm string 217 + dev bool 218 + } 219 + 220 + type ServiceClientOpt func(*ServiceClientOpts) 221 + 222 + func WithService(service string) ServiceClientOpt { 223 + return func(s *ServiceClientOpts) { 224 + s.service = service 225 + } 226 + } 227 + func WithExp(exp int64) ServiceClientOpt { 228 + return func(s *ServiceClientOpts) { 229 + s.exp = exp 230 + } 231 + } 232 + 233 + func WithLxm(lxm string) ServiceClientOpt { 234 + return func(s *ServiceClientOpts) { 235 + s.lxm = lxm 236 + } 237 + } 238 + 239 + func WithDev(dev bool) ServiceClientOpt { 240 + return func(s *ServiceClientOpts) { 241 + s.dev = dev 242 + } 243 + } 244 + 245 + func (s *ServiceClientOpts) Audience() string { 246 + return fmt.Sprintf("did:web:%s", s.service) 247 + } 248 + 249 + func (s *ServiceClientOpts) Host() string { 250 + scheme := "https://" 251 + if s.dev { 252 + scheme = "http://" 253 + } 254 + 255 + return scheme + s.service 256 + } 257 + 258 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 259 + opts := ServiceClientOpts{} 260 + for _, o := range os { 261 + o(&opts) 262 + } 263 + 264 + authorizedClient, err := o.AuthorizedClient(r) 265 + if err != nil { 266 + return nil, err 267 + } 268 + 269 + resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 270 + if err != nil { 271 + return nil, err 272 + } 273 + 274 + return &indigo_xrpc.Client{ 275 + Auth: &indigo_xrpc.AuthInfo{ 276 + AccessJwt: resp.Token, 277 + }, 278 + Host: opts.Host(), 279 + }, nil 280 } 281 282 type ClientMetadata struct {
+67 -31
appview/pages/funcmap.go
··· 17 "time" 18 19 "github.com/dustin/go-humanize" 20 "github.com/microcosm-cc/bluemonday" 21 "tangled.sh/tangled.sh/core/appview/filetree" 22 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 105 s = append(s, values...) 106 return s 107 }, 108 - "timeFmt": humanize.Time, 109 - "longTimeFmt": func(t time.Time) string { 110 - return t.Format("2006-01-02 * 3:04 PM") 111 - }, 112 - "commaFmt": humanize.Comma, 113 - "shortTimeFmt": func(t time.Time) string { 114 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 115 {time.Second, "now", time.Second}, 116 {2 * time.Second, "1s %s", 1}, ··· 129 {math.MaxInt64, "a long while %s", 1}, 130 }) 131 }, 132 - "durationFmt": func(duration time.Duration) string { 133 days := int64(duration.Hours() / 24) 134 hours := int64(math.Mod(duration.Hours(), 24)) 135 minutes := int64(math.Mod(duration.Minutes(), 60)) 136 seconds := int64(math.Mod(duration.Seconds(), 60)) 137 - 138 - chunks := []struct { 139 - name string 140 - amount int64 141 - }{ 142 - {"d", days}, 143 - {"hr", hours}, 144 - {"min", minutes}, 145 - {"s", seconds}, 146 - } 147 - 148 - parts := []string{} 149 - 150 - for _, chunk := range chunks { 151 - if chunk.amount != 0 { 152 - parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 153 - } 154 - } 155 - 156 - return strings.Join(parts, " ") 157 }, 158 "byteFmt": humanize.Bytes, 159 "length": func(slice any) int { ··· 200 if v.Len() == 0 { 201 return nil 202 } 203 - return v.Slice(0, min(n, v.Len()-1)).Interface() 204 }, 205 206 "markdown": func(text string) template.HTML { ··· 250 return u 251 }, 252 253 - "tinyAvatar": p.tinyAvatar, 254 } 255 } 256 257 - func (p *Pages) tinyAvatar(handle string) string { 258 handle = strings.TrimPrefix(handle, "@") 259 secret := p.avatar.SharedSecret 260 h := hmac.New(sha256.New, []byte(secret)) 261 h.Write([]byte(handle)) 262 signature := hex.EncodeToString(h.Sum(nil)) 263 - return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle) 264 } 265 266 func icon(name string, classes []string) (template.HTML, error) { ··· 288 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 289 return template.HTML(modifiedSVG), nil 290 }
··· 17 "time" 18 19 "github.com/dustin/go-humanize" 20 + "github.com/go-enry/go-enry/v2" 21 "github.com/microcosm-cc/bluemonday" 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 106 s = append(s, values...) 107 return s 108 }, 109 + "commaFmt": humanize.Comma, 110 + "relTimeFmt": humanize.Time, 111 + "shortRelTimeFmt": func(t time.Time) string { 112 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 113 {time.Second, "now", time.Second}, 114 {2 * time.Second, "1s %s", 1}, ··· 127 {math.MaxInt64, "a long while %s", 1}, 128 }) 129 }, 130 + "longTimeFmt": func(t time.Time) string { 131 + return t.Format("Jan 2, 2006, 3:04 PM MST") 132 + }, 133 + "iso8601DateTimeFmt": func(t time.Time) string { 134 + return t.Format("2006-01-02T15:04:05-07:00") 135 + }, 136 + "iso8601DurationFmt": func(duration time.Duration) string { 137 days := int64(duration.Hours() / 24) 138 hours := int64(math.Mod(duration.Hours(), 24)) 139 minutes := int64(math.Mod(duration.Minutes(), 60)) 140 seconds := int64(math.Mod(duration.Seconds(), 60)) 141 + return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds) 142 + }, 143 + "durationFmt": func(duration time.Duration) string { 144 + return durationFmt(duration, [4]string{"d", "hr", "min", "s"}) 145 + }, 146 + "longDurationFmt": func(duration time.Duration) string { 147 + return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"}) 148 }, 149 "byteFmt": humanize.Bytes, 150 "length": func(slice any) int { ··· 191 if v.Len() == 0 { 192 return nil 193 } 194 + return v.Slice(0, min(n, v.Len())).Interface() 195 }, 196 197 "markdown": func(text string) template.HTML { ··· 241 return u 242 }, 243 244 + "tinyAvatar": func(handle string) string { 245 + return p.avatarUri(handle, "tiny") 246 + }, 247 + "fullAvatar": func(handle string) string { 248 + return p.avatarUri(handle, "") 249 + }, 250 + "langColor": enry.GetColor, 251 + "layoutSide": func() string { 252 + return "col-span-1 md:col-span-2 lg:col-span-3" 253 + }, 254 + "layoutCenter": func() string { 255 + return "col-span-1 md:col-span-8 lg:col-span-6" 256 + }, 257 } 258 } 259 260 + func (p *Pages) avatarUri(handle, size string) string { 261 handle = strings.TrimPrefix(handle, "@") 262 + 263 secret := p.avatar.SharedSecret 264 h := hmac.New(sha256.New, []byte(secret)) 265 h.Write([]byte(handle)) 266 signature := hex.EncodeToString(h.Sum(nil)) 267 + 268 + sizeArg := "" 269 + if size != "" { 270 + sizeArg = fmt.Sprintf("size=%s", size) 271 + } 272 + return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 273 } 274 275 func icon(name string, classes []string) (template.HTML, error) { ··· 297 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 298 return template.HTML(modifiedSVG), nil 299 } 300 + 301 + func durationFmt(duration time.Duration, names [4]string) string { 302 + days := int64(duration.Hours() / 24) 303 + hours := int64(math.Mod(duration.Hours(), 24)) 304 + minutes := int64(math.Mod(duration.Minutes(), 60)) 305 + seconds := int64(math.Mod(duration.Seconds(), 60)) 306 + 307 + chunks := []struct { 308 + name string 309 + amount int64 310 + }{ 311 + {names[0], days}, 312 + {names[1], hours}, 313 + {names[2], minutes}, 314 + {names[3], seconds}, 315 + } 316 + 317 + parts := []string{} 318 + 319 + for _, chunk := range chunks { 320 + if chunk.amount != 0 { 321 + parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 322 + } 323 + } 324 + 325 + return strings.Join(parts, " ") 326 + }
+2 -2
appview/pages/markup/camo.go
··· 9 "github.com/yuin/goldmark/ast" 10 ) 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 13 h := hmac.New(sha256.New, []byte(secret)) 14 h.Write([]byte(imageURL)) 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 } 25 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 } 29 30 return dst
··· 9 "github.com/yuin/goldmark/ast" 10 ) 11 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 h := hmac.New(sha256.New, []byte(secret)) 14 h.Write([]byte(imageURL)) 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 } 25 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 } 29 30 return dst
+157 -26
appview/pages/pages.go
··· 14 "os" 15 "path/filepath" 16 "strings" 17 18 "tangled.sh/tangled.sh/core/appview/commitverify" 19 "tangled.sh/tangled.sh/core/appview/config" 20 "tangled.sh/tangled.sh/core/appview/db" ··· 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/go-git/go-git/v5/plumbing" 34 "github.com/go-git/go-git/v5/plumbing/object" 35 - "github.com/microcosm-cc/bluemonday" 36 ) 37 38 //go:embed templates/* static 39 var Files embed.FS 40 41 type Pages struct { 42 - t map[string]*template.Template 43 avatar config.AvatarConfig 44 dev bool 45 embedFS embed.FS ··· 56 } 57 58 p := &Pages{ 59 t: make(map[string]*template.Template), 60 dev: config.Core.Dev, 61 avatar: config.Avatar, ··· 147 } 148 149 log.Printf("total templates loaded: %d", len(templates)) 150 p.t = templates 151 } 152 ··· 207 } 208 209 // Update the template in the map 210 p.t[name] = tmpl 211 log.Printf("template reloaded from disk: %s", name) 212 return nil ··· 221 } 222 } 223 224 tmpl, exists := p.t[templateName] 225 if !exists { 226 return fmt.Errorf("template not found: %s", templateName) ··· 252 return p.executePlain("user/login", w, params) 253 } 254 255 type TimelineParams struct { 256 LoggedInUser *oauth.User 257 Timeline []db.TimelineEvent ··· 278 } 279 280 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 281 - return p.execute("knots", w, params) 282 } 283 284 type KnotParams struct { ··· 286 DidHandleMap map[string]string 287 Registration *db.Registration 288 Members []string 289 IsOwner bool 290 } 291 292 func (p *Pages) Knot(w io.Writer, params KnotParams) error { 293 - return p.execute("knot", w, params) 294 } 295 296 type SpindlesParams struct { ··· 413 return p.executePlain("user/fragments/editPins", w, params) 414 } 415 416 - type RepoActionsFragmentParams struct { 417 IsStarred bool 418 RepoAt syntax.ATURI 419 Stats db.RepoStats 420 } 421 422 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 423 - return p.executePlain("repo/fragments/repoActions", w, params) 424 } 425 426 type RepoDescriptionParams struct { ··· 467 ext := filepath.Ext(params.ReadmeFileName) 468 switch ext { 469 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 470 htmlString = p.rctx.RenderMarkdown(params.Readme) 471 params.Raw = false 472 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 473 default: 474 - htmlString = string(params.Readme) 475 params.Raw = true 476 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 477 } 478 } 479 ··· 502 Active string 503 EmailToDidOrHandle map[string]string 504 Pipeline *db.Pipeline 505 506 // singular because it's always going to be just one 507 VerifiedCommit commitverify.VerifiedCommits ··· 519 RepoInfo repoinfo.RepoInfo 520 Active string 521 BreadCrumbs [][]string 522 - BaseTreeLink string 523 - BaseBlobLink string 524 types.RepoTreeResponse 525 } 526 ··· 590 LoggedInUser *oauth.User 591 RepoInfo repoinfo.RepoInfo 592 Active string 593 BreadCrumbs [][]string 594 ShowRendered bool 595 RenderToggle bool ··· 657 Branches []types.Branch 658 Spindles []string 659 CurrentSpindle string 660 // TODO: use repoinfo.roles 661 IsCollaboratorInviteAllowed bool 662 } ··· 666 return p.executeRepo("repo/settings", w, params) 667 } 668 669 type RepoIssuesParams struct { 670 LoggedInUser *oauth.User 671 RepoInfo repoinfo.RepoInfo ··· 690 IssueOwnerHandle string 691 DidHandleMap map[string]string 692 693 State string 694 } 695 696 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 697 params.Active = "issues" 698 if params.Issue.Open { ··· 762 DidHandleMap map[string]string 763 FilteringBy db.PullState 764 Stacks map[string]db.Stack 765 } 766 767 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 798 MergeCheck types.MergeCheckResponse 799 ResubmitCheck ResubmitResult 800 Pipelines map[string]db.Pipeline 801 } 802 803 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 806 } 807 808 type RepoPullPatchParams struct { 809 - LoggedInUser *oauth.User 810 - DidHandleMap map[string]string 811 - RepoInfo repoinfo.RepoInfo 812 - Pull *db.Pull 813 - Stack db.Stack 814 - Diff *types.NiceDiff 815 - Round int 816 - Submission *db.PullSubmission 817 } 818 819 // this name is a mouthful ··· 822 } 823 824 type RepoPullInterdiffParams struct { 825 - LoggedInUser *oauth.User 826 - DidHandleMap map[string]string 827 - RepoInfo repoinfo.RepoInfo 828 - Pull *db.Pull 829 - Round int 830 - Interdiff *patchutil.InterdiffResult 831 } 832 833 // this name is a mouthful ··· 918 Base string 919 Head string 920 Diff *types.NiceDiff 921 922 Active string 923 }
··· 14 "os" 15 "path/filepath" 16 "strings" 17 + "sync" 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview/commitverify" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" ··· 34 "github.com/bluesky-social/indigo/atproto/syntax" 35 "github.com/go-git/go-git/v5/plumbing" 36 "github.com/go-git/go-git/v5/plumbing/object" 37 ) 38 39 //go:embed templates/* static 40 var Files embed.FS 41 42 type Pages struct { 43 + mu sync.RWMutex 44 + t map[string]*template.Template 45 + 46 avatar config.AvatarConfig 47 dev bool 48 embedFS embed.FS ··· 59 } 60 61 p := &Pages{ 62 + mu: sync.RWMutex{}, 63 t: make(map[string]*template.Template), 64 dev: config.Core.Dev, 65 avatar: config.Avatar, ··· 151 } 152 153 log.Printf("total templates loaded: %d", len(templates)) 154 + p.mu.Lock() 155 + defer p.mu.Unlock() 156 p.t = templates 157 } 158 ··· 213 } 214 215 // Update the template in the map 216 + p.mu.Lock() 217 + defer p.mu.Unlock() 218 p.t[name] = tmpl 219 log.Printf("template reloaded from disk: %s", name) 220 return nil ··· 229 } 230 } 231 232 + p.mu.RLock() 233 + defer p.mu.RUnlock() 234 tmpl, exists := p.t[templateName] 235 if !exists { 236 return fmt.Errorf("template not found: %s", templateName) ··· 262 return p.executePlain("user/login", w, params) 263 } 264 265 + type SignupParams struct{} 266 + 267 + func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 + return p.executePlain("user/completeSignup", w, params) 269 + } 270 + 271 + type TermsOfServiceParams struct { 272 + LoggedInUser *oauth.User 273 + } 274 + 275 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 276 + return p.execute("legal/terms", w, params) 277 + } 278 + 279 + type PrivacyPolicyParams struct { 280 + LoggedInUser *oauth.User 281 + } 282 + 283 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 284 + return p.execute("legal/privacy", w, params) 285 + } 286 + 287 type TimelineParams struct { 288 LoggedInUser *oauth.User 289 Timeline []db.TimelineEvent ··· 310 } 311 312 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 313 + return p.execute("knots/index", w, params) 314 } 315 316 type KnotParams struct { ··· 318 DidHandleMap map[string]string 319 Registration *db.Registration 320 Members []string 321 + Repos map[string][]db.Repo 322 IsOwner bool 323 } 324 325 func (p *Pages) Knot(w io.Writer, params KnotParams) error { 326 + return p.execute("knots/dashboard", w, params) 327 + } 328 + 329 + type KnotListingParams struct { 330 + db.Registration 331 + } 332 + 333 + func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 334 + return p.executePlain("knots/fragments/knotListing", w, params) 335 + } 336 + 337 + type KnotListingFullParams struct { 338 + Registrations []db.Registration 339 + } 340 + 341 + func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 342 + return p.executePlain("knots/fragments/knotListingFull", w, params) 343 + } 344 + 345 + type KnotSecretParams struct { 346 + Secret string 347 + } 348 + 349 + func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 350 + return p.executePlain("knots/fragments/secret", w, params) 351 } 352 353 type SpindlesParams struct { ··· 470 return p.executePlain("user/fragments/editPins", w, params) 471 } 472 473 + type RepoStarFragmentParams struct { 474 IsStarred bool 475 RepoAt syntax.ATURI 476 Stats db.RepoStats 477 } 478 479 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 480 + return p.executePlain("repo/fragments/repoStar", w, params) 481 } 482 483 type RepoDescriptionParams struct { ··· 524 ext := filepath.Ext(params.ReadmeFileName) 525 switch ext { 526 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 527 + htmlString = p.rctx.Sanitize(htmlString) 528 htmlString = p.rctx.RenderMarkdown(params.Readme) 529 params.Raw = false 530 + params.HTMLReadme = template.HTML(htmlString) 531 default: 532 params.Raw = true 533 } 534 } 535 ··· 558 Active string 559 EmailToDidOrHandle map[string]string 560 Pipeline *db.Pipeline 561 + DiffOpts types.DiffOpts 562 563 // singular because it's always going to be just one 564 VerifiedCommit commitverify.VerifiedCommits ··· 576 RepoInfo repoinfo.RepoInfo 577 Active string 578 BreadCrumbs [][]string 579 + TreePath string 580 types.RepoTreeResponse 581 } 582 ··· 646 LoggedInUser *oauth.User 647 RepoInfo repoinfo.RepoInfo 648 Active string 649 + Unsupported bool 650 + IsImage bool 651 + IsVideo bool 652 + ContentSrc string 653 BreadCrumbs [][]string 654 ShowRendered bool 655 RenderToggle bool ··· 717 Branches []types.Branch 718 Spindles []string 719 CurrentSpindle string 720 + Secrets []*tangled.RepoListSecrets_Secret 721 + 722 // TODO: use repoinfo.roles 723 IsCollaboratorInviteAllowed bool 724 } ··· 728 return p.executeRepo("repo/settings", w, params) 729 } 730 731 + type RepoGeneralSettingsParams struct { 732 + LoggedInUser *oauth.User 733 + RepoInfo repoinfo.RepoInfo 734 + Active string 735 + Tabs []map[string]any 736 + Tab string 737 + Branches []types.Branch 738 + } 739 + 740 + func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 741 + params.Active = "settings" 742 + return p.executeRepo("repo/settings/general", w, params) 743 + } 744 + 745 + type RepoAccessSettingsParams struct { 746 + LoggedInUser *oauth.User 747 + RepoInfo repoinfo.RepoInfo 748 + Active string 749 + Tabs []map[string]any 750 + Tab string 751 + Collaborators []Collaborator 752 + } 753 + 754 + func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 755 + params.Active = "settings" 756 + return p.executeRepo("repo/settings/access", w, params) 757 + } 758 + 759 + type RepoPipelineSettingsParams struct { 760 + LoggedInUser *oauth.User 761 + RepoInfo repoinfo.RepoInfo 762 + Active string 763 + Tabs []map[string]any 764 + Tab string 765 + Spindles []string 766 + CurrentSpindle string 767 + Secrets []map[string]any 768 + } 769 + 770 + func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 771 + params.Active = "settings" 772 + return p.executeRepo("repo/settings/pipelines", w, params) 773 + } 774 + 775 type RepoIssuesParams struct { 776 LoggedInUser *oauth.User 777 RepoInfo repoinfo.RepoInfo ··· 796 IssueOwnerHandle string 797 DidHandleMap map[string]string 798 799 + OrderedReactionKinds []db.ReactionKind 800 + Reactions map[db.ReactionKind]int 801 + UserReacted map[db.ReactionKind]bool 802 + 803 State string 804 } 805 806 + type ThreadReactionFragmentParams struct { 807 + ThreadAt syntax.ATURI 808 + Kind db.ReactionKind 809 + Count int 810 + IsReacted bool 811 + } 812 + 813 + func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 814 + return p.executePlain("repo/fragments/reaction", w, params) 815 + } 816 + 817 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 818 params.Active = "issues" 819 if params.Issue.Open { ··· 883 DidHandleMap map[string]string 884 FilteringBy db.PullState 885 Stacks map[string]db.Stack 886 + Pipelines map[string]db.Pipeline 887 } 888 889 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 920 MergeCheck types.MergeCheckResponse 921 ResubmitCheck ResubmitResult 922 Pipelines map[string]db.Pipeline 923 + 924 + OrderedReactionKinds []db.ReactionKind 925 + Reactions map[db.ReactionKind]int 926 + UserReacted map[db.ReactionKind]bool 927 } 928 929 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 932 } 933 934 type RepoPullPatchParams struct { 935 + LoggedInUser *oauth.User 936 + DidHandleMap map[string]string 937 + RepoInfo repoinfo.RepoInfo 938 + Pull *db.Pull 939 + Stack db.Stack 940 + Diff *types.NiceDiff 941 + Round int 942 + Submission *db.PullSubmission 943 + OrderedReactionKinds []db.ReactionKind 944 + DiffOpts types.DiffOpts 945 } 946 947 // this name is a mouthful ··· 950 } 951 952 type RepoPullInterdiffParams struct { 953 + LoggedInUser *oauth.User 954 + DidHandleMap map[string]string 955 + RepoInfo repoinfo.RepoInfo 956 + Pull *db.Pull 957 + Round int 958 + Interdiff *patchutil.InterdiffResult 959 + OrderedReactionKinds []db.ReactionKind 960 + DiffOpts types.DiffOpts 961 } 962 963 // this name is a mouthful ··· 1048 Base string 1049 Head string 1050 Diff *types.NiceDiff 1051 + DiffOpts types.DiffOpts 1052 1053 Active string 1054 }
-98
appview/pages/templates/knot.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p> 6 - </div> 7 - 8 - <div class="flex flex-col"> 9 - {{ block "registration-info" . }} {{ end }} 10 - {{ block "members" . }} {{ end }} 11 - {{ block "add-member" . }} {{ end }} 12 - </div> 13 - {{ end }} 14 - 15 - {{ define "registration-info" }} 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 - <dt class="font-bold">opened by</dt> 19 - <dd> 20 - <span> 21 - {{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span> 22 - </span> 23 - {{ if eq $.LoggedInUser.Did $.Registration.ByDid }} 24 - <span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span> 25 - {{ end }} 26 - </dd> 27 - 28 - <dt class="font-bold">opened</dt> 29 - <dd>{{ .Registration.Created | timeFmt }}</dd> 30 - 31 - {{ if .Registration.Registered }} 32 - <dt class="font-bold">registered</dt> 33 - <dd>{{ .Registration.Registered | timeFmt }}</dd> 34 - {{ else }} 35 - <dt class="font-bold">status</dt> 36 - <dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block"> 37 - Pending Registration 38 - </dd> 39 - {{ end }} 40 - </dl> 41 - 42 - {{ if not .Registration.Registered }} 43 - <div class="mt-4"> 44 - <button 45 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 46 - hx-post="/knots/{{.Domain}}/init" 47 - hx-swap="none"> 48 - Initialize Registration 49 - </button> 50 - </div> 51 - {{ end }} 52 - </section> 53 - {{ end }} 54 - 55 - {{ define "members" }} 56 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2> 57 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 58 - {{ if .Registration.Registered }} 59 - <div id="member-list" class="flex flex-col gap-4"> 60 - {{ range $.Members }} 61 - <div class="inline-flex items-center gap-4"> 62 - {{ i "user" "w-4 h-4 dark:text-gray-300" }} 63 - <a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}} 64 - <span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span> 65 - </a> 66 - </div> 67 - {{ else }} 68 - <p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p> 69 - {{ end }} 70 - </div> 71 - {{ else }} 72 - <p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p> 73 - {{ end }} 74 - </section> 75 - {{ end }} 76 - 77 - {{ define "add-member" }} 78 - {{ if $.IsOwner }} 79 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2> 80 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 81 - <form 82 - hx-put="/knots/{{.Registration.Domain}}/member" 83 - class="max-w-2xl space-y-4"> 84 - <input 85 - type="text" 86 - id="subject" 87 - name="subject" 88 - placeholder="did or handle" 89 - required 90 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 91 - 92 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button> 93 - 94 - <div id="add-member-error" class="error dark:text-red-400"></div> 95 - </form> 96 - </section> 97 - {{ end }} 98 - {{ end }}
···
+63
appview/pages/templates/knots/dashboard.html
···
··· 1 + {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 + 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> 18 + {{ template "knots/fragments/addMemberModal" .Registration }} 19 + {{ else }} 20 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 + {{ end }} 22 + </div> 23 + </div> 24 + <div id="operation-error" class="dark:text-red-400"></div> 25 + </div> 26 + 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 }} 34 + {{ end }} 35 + 36 + {{ define "knotMember" }} 37 + {{ range .Members }} 38 + <div> 39 + <div class="flex justify-between items-center"> 40 + <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> 44 + </div> 45 + </div> 46 + <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 47 + {{ $repos := index $.Repos . }} 48 + {{ range $repos }} 49 + <div class="flex gap-2 items-center"> 50 + {{ i "book-marked" "size-4" }} 51 + <a href="/{{ .Did }}/{{ .Name }}"> 52 + {{ .Name }} 53 + </a> 54 + </div> 55 + {{ else }} 56 + <div class="text-gray-500 dark:text-gray-400"> 57 + No repositories created yet. 58 + </div> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }} 63 + {{ end }}
+58
appview/pages/templates/knots/fragments/addMemberModal.html
···
··· 1 + {{ define "knots/fragments/addMemberModal" }} 2 + <button 3 + class="btn gap-2 group" 4 + title="Add member to this spindle" 5 + popovertarget="add-member-{{ .Id }}" 6 + popovertargetaction="toggle" 7 + > 8 + {{ i "user-plus" "w-5 h-5" }} 9 + <span class="hidden md:inline">add member</span> 10 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 11 + </button> 12 + 13 + <div 14 + id="add-member-{{ .Id }}" 15 + popover 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 "addKnotMemberPopover" . }} {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "addKnotMemberPopover" }} 22 + <form 23 + hx-put="/knots/{{ .Domain }}/member" 24 + hx-indicator="#spinner" 25 + hx-swap="none" 26 + class="flex flex-col gap-2" 27 + > 28 + <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 + ADD MEMBER 30 + </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="subject" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 39 + <div class="flex gap-2 pt-2"> 40 + <button 41 + type="button" 42 + popovertarget="add-member-{{ .Id }}" 43 + popovertargetaction="hide" 44 + 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" 45 + > 46 + {{ i "x" "size-4" }} cancel 47 + </button> 48 + <button type="submit" class="btn w-1/2 flex items-center"> 49 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 50 + <span id="spinner" class="group"> 51 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </span> 53 + </button> 54 + </div> 55 + <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 + </form> 57 + {{ end }} 58 +
+51
appview/pages/templates/knots/fragments/knotListing.html
···
··· 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 }} 8 + </div> 9 + {{ end }} 10 + 11 + {{ define "listLeftSide" }} 12 + <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 + {{ 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 }} 21 + <span class="text-gray-500"> 22 + {{ template "repo/fragments/shortTimeAgo" .Created }} 23 + </span> 24 + </div> 25 + {{ end }} 26 + 27 + {{ define "listRightSide" }} 28 + <div id="right-side" class="flex gap-2"> 29 + {{ $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> 32 + {{ template "knots/fragments/addMemberModal" . }} 33 + {{ 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 }} 36 + {{ end }} 37 + </div> 38 + {{ end }} 39 + 40 + {{ define "initializeButton" }} 41 + <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" 44 + hx-swap="none" 45 + > 46 + {{ i "square-play" "w-5 h-5" }} 47 + <span class="hidden md:inline">initialize</span> 48 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 + </button> 50 + {{ 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 }}
+69
appview/pages/templates/knots/index.html
···
··· 1 + {{ define "title" }}knots{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + </div> 7 + 8 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 + <div class="flex flex-col gap-6"> 10 + {{ block "about" . }} {{ end }} 11 + {{ template "knots/fragments/knotListingFull" . }} 12 + {{ block "register" . }} {{ end }} 13 + </div> 14 + </section> 15 + {{ end }} 16 + 17 + {{ define "about" }} 18 + <section class="rounded flex flex-col gap-2"> 19 + <p class="dark:text-gray-300"> 20 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 21 + Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โ€œcommunityโ€ servers. 22 + When creating a repository, you can choose a knot to store it on. 23 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 24 + Checkout the documentation if you're interested in self-hosting. 25 + </a> 26 + </p> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "register" }} 31 + <section class="rounded max-w-2xl flex flex-col gap-2"> 32 + <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> 34 + <form 35 + hx-post="/knots/key" 36 + class="space-y-4" 37 + hx-indicator="#register-button" 38 + hx-swap="none" 39 + > 40 + <div class="flex gap-2"> 41 + <input 42 + type="text" 43 + id="domain" 44 + name="domain" 45 + placeholder="knot.example.com" 46 + required 47 + class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 48 + > 49 + <button 50 + type="submit" 51 + id="register-button" 52 + class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 53 + > 54 + <span class="inline-flex items-center gap-2"> 55 + {{ i "plus" "w-4 h-4" }} 56 + generate 57 + </span> 58 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 60 + </span> 61 + </button> 62 + </div> 63 + 64 + <div id="registration-error" class="error dark:text-red-400"></div> 65 + </form> 66 + 67 + <div id="secret"></div> 68 + </section> 69 + {{ end }}
-93
appview/pages/templates/knots.html
··· 1 - {{ define "title" }}knots{{ end }} 2 - {{ define "content" }} 3 - <div class="p-6"> 4 - <p class="text-xl font-bold dark:text-white">Knots</p> 5 - </div> 6 - <div class="flex flex-col"> 7 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2> 8 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 9 - <p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p> 10 - <form 11 - hx-post="/knots/key" 12 - class="max-w-2xl mb-8 space-y-4" 13 - hx-indicator="#generate-knot-key-spinner" 14 - > 15 - <input 16 - type="text" 17 - id="domain" 18 - name="domain" 19 - placeholder="knot.example.com" 20 - required 21 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 22 - > 23 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit"> 24 - <span>generate key</span> 25 - <span id="generate-knot-key-spinner" class="group"> 26 - {{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 - </span> 28 - </button> 29 - <div id="settings-knots-error" class="error dark:text-red-400"></div> 30 - </form> 31 - </section> 32 - 33 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2> 34 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 35 - <div id="knots-list" class="flex flex-col gap-6 mb-8"> 36 - {{ range .Registrations }} 37 - {{ if .Registered }} 38 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 39 - <div class="flex flex-col gap-1"> 40 - <div class="inline-flex items-center gap-4"> 41 - {{ i "git-branch" "w-3 h-3 dark:text-gray-300" }} 42 - <a href="/knots/{{ .Domain }}"> 43 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 44 - </a> 45 - </div> 46 - <p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p> 47 - <p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p> 48 - </div> 49 - </div> 50 - {{ end }} 51 - {{ else }} 52 - <p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p> 53 - {{ end }} 54 - </div> 55 - </section> 56 - 57 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2> 58 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 59 - <div id="pending-knots-list" class="flex flex-col gap-6 mb-8"> 60 - {{ range .Registrations }} 61 - {{ if not .Registered }} 62 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 63 - <div class="flex flex-col gap-1"> 64 - <div class="inline-flex items-center gap-4"> 65 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 66 - <div class="inline-flex items-center gap-1"> 67 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded"> 68 - pending 69 - </span> 70 - </div> 71 - </div> 72 - <p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p> 73 - <p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p> 74 - </div> 75 - <div class="flex gap-2 items-center"> 76 - <button 77 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 78 - hx-post="/knots/{{ .Domain }}/init" 79 - > 80 - {{ i "square-play" "w-5 h-5" }} 81 - <span class="hidden md:inline">initialize</span> 82 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 83 - </button> 84 - </div> 85 - </div> 86 - {{ end }} 87 - {{ else }} 88 - <p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p> 89 - {{ end }} 90 - </div> 91 - </section> 92 - </div> 93 - {{ end }}
···
+37 -11
appview/pages/templates/layouts/base.html
··· 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 16 </head> 17 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 - <div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col"> 19 - <header style="z-index: 20"> 20 - {{ block "topbar" . }} 21 {{ template "layouts/topbar" . }} 22 - {{ end }} 23 </header> 24 - <main class="content grow">{{ block "content" . }}{{ end }}</main> 25 - <footer class="mt-16"> 26 - {{ block "footer" . }} 27 - {{ template "layouts/footer" . }} 28 - {{ end }} 29 </footer> 30 - </div> 31 </body> 32 </html> 33 {{ end }}
··· 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 16 </head> 17 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 + {{ block "topbarLayout" . }} 19 + <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 {{ template "layouts/topbar" . }} 21 </header> 22 + {{ end }} 23 + 24 + {{ block "mainLayout" . }} 25 + <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 + {{ block "contentLayout" . }} 27 + <div class="col-span-1 md:col-span-2"> 28 + {{ block "contentLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-8"> 31 + {{ block "content" . }}{{ end }} 32 + </main> 33 + <div class="col-span-1 md:col-span-2"> 34 + {{ block "contentRight" . }} {{ end }} 35 + </div> 36 + {{ end }} 37 + 38 + {{ block "contentAfterLayout" . }} 39 + <div class="col-span-1 md:col-span-2"> 40 + {{ block "contentAfterLeft" . }} {{ end }} 41 + </div> 42 + <main class="col-span-1 md:col-span-8"> 43 + {{ block "contentAfter" . }}{{ end }} 44 + </main> 45 + <div class="col-span-1 md:col-span-2"> 46 + {{ block "contentAfterRight" . }} {{ end }} 47 + </div> 48 + {{ end }} 49 + </div> 50 + {{ end }} 51 + 52 + {{ block "footerLayout" . }} 53 + <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 + {{ template "layouts/footer" . }} 55 </footer> 56 + {{ end }} 57 </body> 58 </html> 59 {{ end }}
+41 -3
appview/pages/templates/layouts/footer.html
··· 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 </div> 6 </div> 7 {{ end }}
··· 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 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 12 + <div class="flex flex-col gap-1"> 13 + <div class="font-medium text-xs uppercase tracking-wide mb-1">legal</div> 14 + <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline flex gap-1 items-center">{{ i "file-text" "w-4 h-4 flex-shrink-0" }} terms of service</a> 15 + <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "shield" "w-4 h-4 flex-shrink-0" }} privacy policy</a> 16 + </div> 17 + 18 + <div class="flex flex-col gap-1"> 19 + <div class="font-medium text-xs uppercase tracking-wide mb-1">resources</div> 20 + <a href="https://blog.tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "book-open" "w-4 h-4 flex-shrink-0" }} blog</a> 21 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "book" "w-4 h-4 flex-shrink-0" }} docs</a> 22 + <a href="https://tangled.sh/@tangled.sh/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "code" "w-4 h-4 flex-shrink-0" }} source</a> 23 + </div> 24 + 25 + <div class="flex flex-col gap-1"> 26 + <div class="font-medium text-xs uppercase tracking-wide mb-1">social</div> 27 + <a href="https://chat.tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "message-circle" "w-4 h-4 flex-shrink-0" }} discord</a> 28 + <a href="https://web.libera.chat/#tangled" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "hash" "w-4 h-4 flex-shrink-0" }} irc</a> 29 + <a href="https://bsky.app/profile/tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" "w-4 h-4 flex-shrink-0 hover:text-gray-900 dark:hover:text-gray-200dark:text-white" }} bluesky</a> 30 + </div> 31 + 32 + <div class="flex flex-col gap-1"> 33 + <div class="font-medium text-xs uppercase tracking-wide mb-1">contact</div> 34 + <a href="mailto:team@tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 35 + <a href="mailto:security@tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 36 + </div> 37 + </div> 38 + 39 + <div class="text-center lg:text-right flex-shrink-0"> 40 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 41 + </div> 42 + </div> 43 </div> 44 </div> 45 {{ end }}
+26 -4
appview/pages/templates/layouts/repobase.html
··· 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 </div> 21 22 - {{ template "repo/fragments/repoActions" .RepoInfo }} 23 </div> 24 {{ template "repo/fragments/repoDescription" . }} 25 </section> 26 27 <section 28 - class="min-h-screen w-full flex flex-col drop-shadow-sm" 29 > 30 <nav class="w-full pl-4 overflow-auto"> 31 <div class="flex z-60"> ··· 47 {{ if eq $.Active $key }} 48 {{ $activeTabStyles }} 49 {{ else }} 50 - group-hover:bg-gray-200 dark:group-hover:bg-gray-700 51 {{ end }} 52 " 53 > ··· 64 </div> 65 </nav> 66 <section 67 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white" 68 > 69 {{ block "repoContent" . }}{{ end }} 70 </section>
··· 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 </div> 21 22 + <div class="flex items-center gap-2 z-auto"> 23 + {{ 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 }} 44 + </div> 45 </div> 46 {{ template "repo/fragments/repoDescription" . }} 47 </section> 48 49 <section 50 + class="w-full flex flex-col drop-shadow-sm" 51 > 52 <nav class="w-full pl-4 overflow-auto"> 53 <div class="flex z-60"> ··· 69 {{ if eq $.Active $key }} 70 {{ $activeTabStyles }} 71 {{ else }} 72 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 73 {{ end }} 74 " 75 > ··· 86 </div> 87 </nav> 88 <section 89 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 90 > 91 {{ block "repoContent" . }}{{ end }} 92 </section>
+7 -17
appview/pages/templates/layouts/topbar.html
··· 1 {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="container 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 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 - 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 <div id="right-items" class="flex items-center gap-4"> 23 {{ with .LoggedInUser }} 24 <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> ··· 36 {{ define "dropDown" }} 37 <details class="relative inline-block text-left"> 38 <summary 39 - class="cursor-pointer list-none" 40 > 41 - {{ didOrHandle .Did .Handle }} 42 </summary> 43 <div 44 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" 45 > 46 - <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 47 <a href="/knots">knots</a> 48 <a href="/spindles">spindles</a> 49 <a href="/settings">settings</a>
··· 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-4"> 11 {{ with .LoggedInUser }} 12 <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> ··· 24 {{ define "dropDown" }} 25 <details class="relative inline-block text-left"> 26 <summary 27 + class="cursor-pointer list-none flex items-center" 28 > 29 + {{ $user := didOrHandle .Did .Handle }} 30 + {{ template "user/fragments/picHandle" $user }} 31 </summary> 32 <div 33 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" 34 > 35 + <a href="/{{ $user }}">profile</a> 36 + <a href="/{{ $user }}?tab=repos">repositories</a> 37 <a href="/knots">knots</a> 38 <a href="/spindles">spindles</a> 39 <a href="/settings">settings</a>
+133
appview/pages/templates/legal/privacy.html
···
··· 1 + {{ define "title" }} privacy policy {{ end }} 2 + {{ define "content" }} 3 + <div class="max-w-4xl mx-auto px-4 py-8"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 + <h1>Privacy Policy</h1> 7 + 8 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 + 10 + <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 + 12 + <h2>1. Information We Collect</h2> 13 + 14 + <h3>Account Information</h3> 15 + <p>When you create an account, we collect:</p> 16 + <ul> 17 + <li>Your chosen username</li> 18 + <li>Email address</li> 19 + <li>Profile information you choose to provide</li> 20 + <li>Authentication data</li> 21 + </ul> 22 + 23 + <h3>Content and Activity</h3> 24 + <p>We store:</p> 25 + <ul> 26 + <li>Code repositories and associated metadata</li> 27 + <li>Issues, pull requests, and comments</li> 28 + <li>Activity logs and usage patterns</li> 29 + <li>Public keys for authentication</li> 30 + </ul> 31 + 32 + <h2>2. Data Location and Hosting</h2> 33 + <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 + <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 + <p class="text-blue-700 dark:text-blue-300"> 36 + <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 + </p> 38 + <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 + <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 + <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 + <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 + </ul> 43 + </div> 44 + 45 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 + <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 + <p class="text-yellow-700 dark:text-yellow-300"> 48 + <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 + </p> 50 + </div> 51 + 52 + <h2>3. Third-Party Data Processors</h2> 53 + <p>We only share your data with the following third-party processors:</p> 54 + 55 + <h3>Resend (Email Services)</h3> 56 + <ul> 57 + <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 + <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 + <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 + </ul> 61 + 62 + <h3>Cloudflare (Image Caching)</h3> 63 + <ul> 64 + <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 + <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 + <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 + </ul> 68 + 69 + <h2>4. How We Use Your Information</h2> 70 + <p>We use your information to:</p> 71 + <ul> 72 + <li>Provide and maintain the Service</li> 73 + <li>Process your transactions and requests</li> 74 + <li>Send you technical notices and support messages</li> 75 + <li>Improve and develop new features</li> 76 + <li>Ensure security and prevent fraud</li> 77 + <li>Comply with legal obligations</li> 78 + </ul> 79 + 80 + <h2>5. Data Sharing and Disclosure</h2> 81 + <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 + <ul> 83 + <li>With the third-party processors listed above</li> 84 + <li>When required by law or legal process</li> 85 + <li>To protect our rights, property, or safety, or that of our users</li> 86 + <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 + </ul> 88 + 89 + <h2>6. Data Security</h2> 90 + <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 + 92 + <h2>7. Data Retention</h2> 93 + <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 + 95 + <h2>8. Your Rights</h2> 96 + <p>Under applicable data protection laws, you have the right to:</p> 97 + <ul> 98 + <li>Access your personal information</li> 99 + <li>Correct inaccurate information</li> 100 + <li>Request deletion of your information</li> 101 + <li>Object to processing of your information</li> 102 + <li>Data portability</li> 103 + <li>Withdraw consent (where applicable)</li> 104 + </ul> 105 + 106 + <h2>9. Cookies and Tracking</h2> 107 + <p>We use cookies and similar technologies to:</p> 108 + <ul> 109 + <li>Maintain your login session</li> 110 + <li>Remember your preferences</li> 111 + <li>Analyze usage patterns to improve the Service</li> 112 + </ul> 113 + <p>You can control cookie settings through your browser preferences.</p> 114 + 115 + <h2>10. Children's Privacy</h2> 116 + <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 + 118 + <h2>11. International Data Transfers</h2> 119 + <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 + 121 + <h2>12. Changes to This Privacy Policy</h2> 122 + <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 + 124 + <h2>13. Contact Information</h2> 125 + <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 + 127 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 + <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 130 + </div> 131 + </div> 132 + </div> 133 + {{ end }}
+71
appview/pages/templates/legal/terms.html
···
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + <h1>Terms of Service</h1> 8 + 9 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 + 11 + <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 + 13 + <h2>1. Acceptance of Terms</h2> 14 + <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 + 16 + <h2>2. Account Registration</h2> 17 + <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 + 19 + <h2>3. Account Termination</h2> 20 + <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 + <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 + <p class="text-red-700 dark:text-red-300"> 23 + <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 + </p> 25 + <p class="text-red-700 dark:text-red-300 mt-2"> 26 + Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 + </p> 28 + </div> 29 + 30 + <h2>4. Acceptable Use</h2> 31 + <p>You agree not to use the Service to:</p> 32 + <ul> 33 + <li>Violate any applicable laws or regulations</li> 34 + <li>Infringe upon the rights of others</li> 35 + <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 + <li>Engage in spam, phishing, or other deceptive practices</li> 37 + <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 + <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 + </ul> 40 + 41 + <h2>5. Content and Intellectual Property</h2> 42 + <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 + 44 + <h2>6. Privacy</h2> 45 + <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 + 47 + <h2>7. Disclaimers</h2> 48 + <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 + 50 + <h2>8. Limitation of Liability</h2> 51 + <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 + 53 + <h2>9. Indemnification</h2> 54 + <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 + 56 + <h2>10. Governing Law</h2> 57 + <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 + 59 + <h2>11. Changes to Terms</h2> 60 + <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 + 62 + <h2>12. Contact Information</h2> 63 + <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 + 65 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 + <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 68 + </div> 69 + </div> 70 + </div> 71 + {{ end }}
+19 -6
appview/pages/templates/repo/blob.html
··· 5 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 11 {{ end }} 12 13 {{ define "repoContent" }} ··· 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 {{ if .RenderToggle }} 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 hx-boost="true" 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 {{ end }} 52 </div> 53 </div> 54 </div> 55 - {{ if .IsBinary }} 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 58 </p> 59 {{ else }} 60 <div class="overflow-auto relative"> 61 {{ if .ShowRendered }}
··· 5 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 + 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 + 11 {{ end }} 12 13 {{ define "repoContent" }} ··· 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 {{ if .RenderToggle }} 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 hx-boost="true" 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 {{ end }} 52 </div> 53 </div> 54 </div> 55 + {{ if and .IsBinary .Unsupported }} 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 + Previews are not supported for this file type. 58 </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 72 {{ else }} 73 <div class="overflow-auto relative"> 74 {{ if .ShowRendered }}
+2 -2
appview/pages/templates/repo/branches.html
··· 59 </td> 60 <td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400"> 61 {{ if .Commit }} 62 - {{ .Commit.Committer.When | timeFmt }} 63 {{ end }} 64 </td> 65 </tr> ··· 98 </a> 99 </span> 100 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 101 - <span>{{ .Commit.Committer.When | timeFmt }}</span> 102 </div> 103 {{ end }} 104 </div>
··· 59 </td> 60 <td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400"> 61 {{ if .Commit }} 62 + {{ template "repo/fragments/time" .Commit.Committer.When }} 63 {{ end }} 64 </td> 65 </tr> ··· 98 </a> 99 </span> 100 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 101 + {{ template "repo/fragments/time" .Commit.Committer.When }} 102 </div> 103 {{ end }} 104 </div>
+43 -6
appview/pages/templates/repo/commit.html
··· 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 {{ end }} 36 <span class="px-1 select-none before:content-['\00B7']"></span> 37 - {{ timeFmt $commit.Author.When }} 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 </p> 40 ··· 59 <div class="flex items-center gap-2 my-2"> 60 {{ i "user" "w-4 h-4" }} 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a> 63 </div> 64 <div class="my-1 pt-2 text-xs border-t"> 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 77 </div> 78 79 </section> 80 81 {{end}} 82 83 - {{ define "repoAfter" }} 84 - <div class="-z-[9999]"> 85 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 86 - </div> 87 {{end}}
··· 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 {{ end }} 36 <span class="px-1 select-none before:content-['\00B7']"></span> 37 + {{ template "repo/fragments/time" $commit.Author.When }} 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 </p> 40 ··· 59 <div class="flex items-center gap-2 my-2"> 60 {{ i "user" "w-4 h-4" }} 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 + <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 </div> 64 <div class="my-1 pt-2 text-xs border-t"> 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 77 </div> 78 79 </section> 80 + {{end}} 81 82 + {{ define "topbarLayout" }} 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/topbar" . }} 85 + </header> 86 + {{ end }} 87 + 88 + {{ define "mainLayout" }} 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 + {{ block "contentLayout" . }} 91 + {{ block "content" . }}{{ end }} 92 + {{ end }} 93 + 94 + {{ block "contentAfterLayout" . }} 95 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 96 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 97 + {{ block "contentAfterLeft" . }} {{ end }} 98 + </div> 99 + <main class="col-span-1 md:col-span-10"> 100 + {{ block "contentAfter" . }}{{ end }} 101 + </main> 102 + </div> 103 + {{ end }} 104 + </div> 105 + {{ end }} 106 + 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/footer" . }} 110 + </footer> 111 + {{ end }} 112 + 113 + {{ define "contentAfter" }} 114 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 115 {{end}} 116 117 + {{ define "contentAfterLeft" }} 118 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 + </div> 121 + <div class="sticky top-0 flex-grow max-h-screen"> 122 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 + </div> 124 {{end}}
+42 -2
appview/pages/templates/repo/compare/compare.html
··· 10 {{ end }} 11 {{ end }} 12 13 - {{ define "repoAfter" }} 14 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 15 {{ end }}
··· 10 {{ end }} 11 {{ end }} 12 13 + {{ define "topbarLayout" }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 17 {{ end }} 18 + 19 + {{ define "mainLayout" }} 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 + {{ block "contentLayout" . }} 22 + {{ block "content" . }}{{ end }} 23 + {{ end }} 24 + 25 + {{ block "contentAfterLayout" . }} 26 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 + {{ block "contentAfterLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-10"> 31 + {{ block "contentAfter" . }}{{ end }} 32 + </main> 33 + </div> 34 + {{ end }} 35 + </div> 36 + {{ end }} 37 + 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/footer" . }} 41 + </footer> 42 + {{ end }} 43 + 44 + {{ define "contentAfter" }} 45 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 46 + {{end}} 47 + 48 + {{ define "contentAfterLeft" }} 49 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 + </div> 52 + <div class="sticky top-0 flex-grow max-h-screen"> 53 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 + </div> 55 + {{end}}
+1 -1
appview/pages/templates/repo/compare/new.html
··· 19 <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 20 <div class="flex items-center justify-between p-2"> 21 {{ $br.Name }} 22 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 23 </div> 24 </a> 25 {{ end }}
··· 19 <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 20 <div class="flex items-center justify-between p-2"> 21 {{ $br.Name }} 22 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 23 </div> 24 </a> 25 {{ end }}
+16 -4
appview/pages/templates/repo/empty.html
··· 17 <a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline"> 18 <div class="flex items-center justify-between p-2"> 19 {{ $br.Name }} 20 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 21 </div> 22 </a> 23 {{ end }} 24 </div> 25 </div> 26 {{ else }} 27 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 28 - This is an empty repository. Push some commits here. 29 - </p> 30 {{ end }} 31 </main> 32 {{ end }}
··· 17 <a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline"> 18 <div class="flex items-center justify-between p-2"> 19 {{ $br.Name }} 20 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 21 </div> 22 </a> 23 {{ end }} 24 </div> 25 </div> 26 + {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 + {{ $knot := .RepoInfo.Knot }} 28 + {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.sh" }} 30 + {{ end }} 31 + <div class="w-full flex place-content-center"> 32 + <div class="py-6 w-fit flex flex-col gap-4"> 33 + <p>This is an empty repository. To get started:</p> 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> 38 + </div> 39 + </div> 40 {{ else }} 41 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 42 {{ end }} 43 </main> 44 {{ end }}
+2 -2
appview/pages/templates/repo/fragments/artifact.html
··· 10 </div> 11 12 <div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm"> 13 - <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span> 14 - <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span> 15 16 <span class="select-none after:content-['ยท'] hidden md:inline"></span> 17 <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
··· 10 </div> 11 12 <div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm"> 13 + <span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span> 14 + <span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span> 15 16 <span class="select-none after:content-['ยท'] hidden md:inline"></span> 17 <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
+90 -145
appview/pages/templates/repo/fragments/diff.html
··· 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $commit := $diff.Commit }} 5 - {{ $stat := $diff.Stat }} 6 - {{ $fileTree := fileTree $diff.ChangedFiles }} 7 - {{ $diff := $diff.Diff }} 8 9 {{ $this := $commit.This }} 10 {{ $parent := $commit.Parent }} 11 12 - <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 13 - <div class="diff-stat"> 14 - <div class="flex gap-2 items-center"> 15 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 16 - {{ block "statPill" $stat }} {{ end }} 17 - </div> 18 - {{ block "fileTree" $fileTree }} {{ end }} 19 - </div> 20 - </section> 21 22 - {{ $last := sub (len $diff) 1 }} 23 - {{ range $idx, $hunk := $diff }} 24 - {{ with $hunk }} 25 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 26 - <div id="file-{{ .Name.New }}"> 27 - <div id="diff-file"> 28 - <details open> 29 - <summary class="list-none cursor-pointer sticky top-0"> 30 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 32 - <div class="flex gap-1 items-center"> 33 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 34 - {{ if .IsNew }} 35 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 36 - {{ else if .IsDelete }} 37 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 38 - {{ else if .IsCopy }} 39 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 40 - {{ else if .IsRename }} 41 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 42 - {{ else }} 43 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 44 - {{ end }} 45 46 - {{ block "statPill" .Stats }} {{ end }} 47 </div> 48 49 - <div class="flex gap-2 items-center overflow-x-auto"> 50 - {{ if .IsDelete }} 51 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 - {{ .Name.Old }} 53 - </a> 54 - {{ else if (or .IsCopy .IsRename) }} 55 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 56 - {{ .Name.Old }} 57 - </a> 58 - {{ i "arrow-right" "w-4 h-4" }} 59 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 60 - {{ .Name.New }} 61 - </a> 62 {{ else }} 63 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 64 - {{ .Name.New }} 65 - </a> 66 {{ end }} 67 - </div> 68 </div> 69 70 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 71 - <div id="right-side-items" class="p-2 flex items-center"> 72 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 73 - {{ if gt $idx 0 }} 74 - {{ $prev := index $diff (sub $idx 1) }} 75 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 76 - {{ end }} 77 78 - {{ if lt $idx $last }} 79 - {{ $next := index $diff (add $idx 1) }} 80 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 81 - {{ end }} 82 - </div> 83 - 84 - </div> 85 - </summary> 86 - 87 - <div class="transition-all duration-700 ease-in-out"> 88 - {{ if .IsDelete }} 89 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 90 - This file has been deleted. 91 - </p> 92 - {{ else if .IsCopy }} 93 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 94 - This file has been copied. 95 - </p> 96 - {{ else if .IsBinary }} 97 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 98 - This is a binary file and will not be displayed. 99 - </p> 100 - {{ else }} 101 - {{ $name := .Name.New }} 102 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 103 - {{- $oldStart := .OldPosition -}} 104 - {{- $newStart := .NewPosition -}} 105 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 106 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 107 - {{- $lineNrSepStyle1 := "" -}} 108 - {{- $lineNrSepStyle2 := "pr-2" -}} 109 - {{- range .Lines -}} 110 - {{- if eq .Op.String "+" -}} 111 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 112 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 113 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 114 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 115 - <div class="px-2">{{ .Line }}</div> 116 - </div> 117 - {{- $newStart = add64 $newStart 1 -}} 118 - {{- end -}} 119 - {{- if eq .Op.String "-" -}} 120 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 121 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 122 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 123 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 124 - <div class="px-2">{{ .Line }}</div> 125 - </div> 126 - {{- $oldStart = add64 $oldStart 1 -}} 127 - {{- end -}} 128 - {{- if eq .Op.String " " -}} 129 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 130 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 131 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 132 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 133 - <div class="px-2">{{ .Line }}</div> 134 - </div> 135 - {{- $newStart = add64 $newStart 1 -}} 136 - {{- $oldStart = add64 $oldStart 1 -}} 137 - {{- end -}} 138 - {{- end -}} 139 - {{- end -}}</div></div></pre> 140 - {{- end -}} 141 </div> 142 - 143 - </details> 144 - 145 - </div> 146 - </div> 147 - </section> 148 - {{ end }} 149 - {{ end }} 150 - {{ end }} 151 - 152 - {{ define "statPill" }} 153 - <div class="flex items-center font-mono text-sm"> 154 - {{ if and .Insertions .Deletions }} 155 - <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 156 - <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 157 - {{ else if .Insertions }} 158 - <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 159 - {{ else if .Deletions }} 160 - <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 161 {{ end }} 162 </div> 163 {{ end }}
··· 1 {{ define "repo/fragments/diff" }} 2 + {{ $repo := index . 0 }} 3 + {{ $diff := index . 1 }} 4 + {{ $opts := index . 2 }} 5 6 + {{ $commit := $diff.Commit }} 7 + {{ $diff := $diff.Diff }} 8 + {{ $isSplit := $opts.Split }} 9 {{ $this := $commit.This }} 10 {{ $parent := $commit.Parent }} 11 + {{ $last := sub (len $diff) 1 }} 12 13 + <div class="flex flex-col gap-4"> 14 + {{ range $idx, $hunk := $diff }} 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> 77 78 + <div class="transition-all duration-700 ease-in-out"> 79 + {{ if .IsDelete }} 80 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 81 + This file has been deleted. 82 + </p> 83 + {{ else if .IsCopy }} 84 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 85 + This file has been copied. 86 + </p> 87 + {{ else if .IsBinary }} 88 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 89 + This is a binary file and will not be displayed. 90 + </p> 91 + {{ else }} 92 + {{ if $isSplit }} 93 + {{- template "repo/fragments/splitDiff" .Split -}} 94 {{ else }} 95 + {{- template "repo/fragments/unifiedDiff" . -}} 96 {{ end }} 97 + {{- end -}} 98 </div> 99 100 + </details> 101 102 </div> 103 + </div> 104 + </section> 105 + {{ end }} 106 {{ end }} 107 </div> 108 {{ end }}
+13
appview/pages/templates/repo/fragments/diffChangedFiles.html
···
··· 1 + {{ define "repo/fragments/diffChangedFiles" }} 2 + {{ $stat := .Stat }} 3 + {{ $fileTree := fileTree .ChangedFiles }} 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 + <div class="diff-stat"> 6 + <div class="flex gap-2 items-center"> 7 + <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 + {{ template "repo/fragments/diffStatPill" $stat }} 9 + </div> 10 + {{ template "repo/fragments/fileTree" $fileTree }} 11 + </div> 12 + </section> 13 + {{ end }}
+28
appview/pages/templates/repo/fragments/diffOpts.html
···
··· 1 + {{ define "repo/fragments/diffOpts" }} 2 + <section class="flex flex-col gap-2 overflow-x-auto text-sm 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"> 3 + <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 + {{ $active := "unified" }} 5 + {{ if .Split }} 6 + {{ $active = "split" }} 7 + {{ end }} 8 + {{ $values := list "unified" "split" }} 9 + {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 10 + </section> 11 + {{ end }} 12 + 13 + {{ define "tabSelector" }} 14 + {{ $name := .Name }} 15 + {{ $all := .Values }} 16 + {{ $active := .Active }} 17 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 + {{ range $index, $value := $all }} 21 + {{ $isActive := eq $value $active }} 22 + <a href="?{{ $name }}={{ $value }}" 23 + class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 + {{ $value }} 25 + </a> 26 + {{ end }} 27 + </div> 28 + {{ end }}
+13
appview/pages/templates/repo/fragments/diffStatPill.html
···
··· 1 + {{ define "repo/fragments/diffStatPill" }} 2 + <div class="flex items-center font-mono text-sm"> 3 + {{ if and .Insertions .Deletions }} 4 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 5 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 6 + {{ else if .Insertions }} 7 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 8 + {{ else if .Deletions }} 9 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 +
+27
appview/pages/templates/repo/fragments/fileTree.html
···
··· 1 + {{ define "repo/fragments/fileTree" }} 2 + {{ if and .Name .IsDirectory }} 3 + <details open> 4 + <summary class="cursor-pointer list-none pt-1"> 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> 8 + </span> 9 + </summary> 10 + <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> 11 + {{ range $child := .Children }} 12 + {{ template "repo/fragments/fileTree" $child }} 13 + {{ end }} 14 + </div> 15 + </details> 16 + {{ else if .Name }} 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> 20 + </div> 21 + {{ else }} 22 + {{ range $child := .Children }} 23 + {{ template "repo/fragments/fileTree" $child }} 24 + {{ end }} 25 + {{ end }} 26 + {{ end }} 27 +
-27
appview/pages/templates/repo/fragments/filetree.html
··· 1 - {{ define "fileTree" }} 2 - {{ if and .Name .IsDirectory }} 3 - <details open> 4 - <summary class="cursor-pointer list-none pt-1"> 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> 8 - </span> 9 - </summary> 10 - <div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700"> 11 - {{ range $child := .Children }} 12 - {{ block "fileTree" $child }} {{ end }} 13 - {{ end }} 14 - </div> 15 - </details> 16 - {{ else if .Name }} 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> 20 - </div> 21 - {{ else }} 22 - {{ range $child := .Children }} 23 - {{ block "fileTree" $child }} {{ end }} 24 - {{ end }} 25 - {{ end }} 26 - {{ end }} 27 -
···
+72 -123
appview/pages/templates/repo/fragments/interdiff.html
··· 1 {{ define "repo/fragments/interdiff" }} 2 {{ $repo := index . 0 }} 3 {{ $x := index . 1 }} 4 {{ $fileTree := fileTree $x.AffectedFiles }} 5 {{ $diff := $x.Files }} 6 7 - <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 8 - <div class="diff-stat"> 9 - <div class="flex gap-2 items-center"> 10 - <strong class="text-sm uppercase dark:text-gray-200">files</strong> 11 - </div> 12 - {{ block "fileTree" $fileTree }} {{ end }} 13 - </div> 14 - </section> 15 - 16 - {{ $last := sub (len $diff) 1 }} 17 {{ range $idx, $hunk := $diff }} 18 - {{ with $hunk }} 19 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 20 - <div id="file-{{ .Name }}"> 21 - <div id="diff-file"> 22 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 23 - <summary class="list-none cursor-pointer sticky top-0"> 24 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 25 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 26 - <div class="flex gap-1 items-center" style="direction: ltr;"> 27 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 28 - {{ if .Status.IsOk }} 29 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 30 - {{ else if .Status.IsUnchanged }} 31 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 32 - {{ else if .Status.IsOnlyInOne }} 33 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 34 - {{ else if .Status.IsOnlyInTwo }} 35 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 36 - {{ else if .Status.IsRebased }} 37 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 38 - {{ else }} 39 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 40 - {{ end }} 41 </div> 42 43 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 44 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 45 - {{ .Name }} 46 - </a> 47 </div> 48 </div> 49 50 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 51 - <div id="right-side-items" class="p-2 flex items-center"> 52 - <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 53 - {{ if gt $idx 0 }} 54 - {{ $prev := index $diff (sub $idx 1) }} 55 - <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 56 {{ end }} 57 - 58 - {{ if lt $idx $last }} 59 - {{ $next := index $diff (add $idx 1) }} 60 - <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 61 - {{ end }} 62 - </div> 63 - 64 </div> 65 - </summary> 66 67 - <div class="transition-all duration-700 ease-in-out"> 68 - {{ if .Status.IsUnchanged }} 69 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 70 - This file has not been changed. 71 - </p> 72 - {{ else if .Status.IsRebased }} 73 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 74 - This patch was likely rebased, as context lines do not match. 75 - </p> 76 - {{ else if .Status.IsError }} 77 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 78 - Failed to calculate interdiff for this file. 79 - </p> 80 - {{ else }} 81 - {{ $name := .Name }} 82 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 83 - {{- $oldStart := .OldPosition -}} 84 - {{- $newStart := .NewPosition -}} 85 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 86 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 87 - {{- $lineNrSepStyle1 := "" -}} 88 - {{- $lineNrSepStyle2 := "pr-2" -}} 89 - {{- range .Lines -}} 90 - {{- if eq .Op.String "+" -}} 91 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 92 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 93 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 94 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 95 - <div class="px-2">{{ .Line }}</div> 96 - </div> 97 - {{- $newStart = add64 $newStart 1 -}} 98 - {{- end -}} 99 - {{- if eq .Op.String "-" -}} 100 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 101 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 102 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 103 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 104 - <div class="px-2">{{ .Line }}</div> 105 - </div> 106 - {{- $oldStart = add64 $oldStart 1 -}} 107 - {{- end -}} 108 - {{- if eq .Op.String " " -}} 109 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 110 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 111 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 112 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 113 - <div class="px-2">{{ .Line }}</div> 114 - </div> 115 - {{- $newStart = add64 $newStart 1 -}} 116 - {{- $oldStart = add64 $oldStart 1 -}} 117 - {{- end -}} 118 - {{- end -}} 119 - {{- end -}}</div></div></pre> 120 - {{- end -}} 121 - </div> 122 123 - </details> 124 - 125 </div> 126 - </div> 127 - </section> 128 - {{ end }} 129 {{ end }} 130 {{ end }} 131 132 - {{ define "statPill" }} 133 - <div class="flex items-center font-mono text-sm"> 134 - {{ if and .Insertions .Deletions }} 135 - <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 136 - <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 137 - {{ else if .Insertions }} 138 - <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 139 - {{ else if .Deletions }} 140 - <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 141 - {{ end }} 142 - </div> 143 - {{ end }}
··· 1 {{ define "repo/fragments/interdiff" }} 2 {{ $repo := index . 0 }} 3 {{ $x := index . 1 }} 4 + {{ $opts := index . 2 }} 5 {{ $fileTree := fileTree $x.AffectedFiles }} 6 {{ $diff := $x.Files }} 7 + {{ $last := sub (len $diff) 1 }} 8 + {{ $isSplit := $opts.Split }} 9 10 + <div class="flex flex-col gap-4"> 11 {{ range $idx, $hunk := $diff }} 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 + 58 </div> 59 + </summary> 60 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 -}} 81 </div> 82 83 + </details> 84 85 + </div> 86 </div> 87 + </section> 88 + {{ end }} 89 {{ end }} 90 + </div> 91 {{ end }} 92
+11
appview/pages/templates/repo/fragments/interdiffFiles.html
···
··· 1 + {{ define "repo/fragments/interdiffFiles" }} 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"> 4 + <div class="diff-stat"> 5 + <div class="flex gap-2 items-center"> 6 + <strong class="text-sm uppercase dark:text-gray-200">files</strong> 7 + </div> 8 + {{ template "repo/fragments/fileTree" $fileTree }} 9 + </div> 10 + </section> 11 + {{ end }}
+34
appview/pages/templates/repo/fragments/reaction.html
···
··· 1 + {{ define "repo/fragments/reaction" }} 2 + <button 3 + id="reactIndi-{{ .Kind }}" 4 + class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 6 + {{ if eq .Count 0 }} 7 + hidden 8 + {{ end }} 9 + {{ if .IsReacted }} 10 + bg-sky-100 11 + border-sky-400 12 + dark:bg-sky-900 13 + dark:border-sky-500 14 + {{ else }} 15 + border-gray-200 16 + hover:bg-gray-50 17 + hover:border-gray-300 18 + dark:border-gray-700 19 + dark:hover:bg-gray-700 20 + dark:hover:border-gray-600 21 + {{ end }} 22 + " 23 + {{ if .IsReacted }} 24 + hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 + {{ else }} 26 + hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 27 + {{ end }} 28 + hx-swap="outerHTML" 29 + hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})" 30 + hx-disabled-elt="this" 31 + > 32 + <span>{{ .Kind }}</span> <span>{{ .Count }}</span> 33 + </button> 34 + {{ end }}
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
···
··· 1 + {{ define "repo/fragments/reactionsPopUp" }} 2 + <details 3 + id="reactionsPopUp" 4 + class="relative inline-block" 5 + > 6 + <summary 7 + class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 8 + hover:bg-gray-50 9 + hover:border-gray-300 10 + dark:hover:bg-gray-700 11 + dark:hover:border-gray-600 12 + cursor-pointer list-none" 13 + > 14 + {{ i "smile" "size-4" }} 15 + </summary> 16 + <div 17 + class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg" 18 + > 19 + {{ range $kind := . }} 20 + <button 21 + id="reactBtn-{{ $kind }}" 22 + class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700" 23 + hx-on:click="this.parentElement.parentElement.removeAttribute('open')" 24 + > 25 + {{ $kind }} 26 + </button> 27 + {{ end }} 28 + </div> 29 + </details> 30 + {{ end }}
-48
appview/pages/templates/repo/fragments/repoActions.html
··· 1 - {{ define "repo/fragments/repoActions" }} 2 - <div class="flex items-center gap-2 z-auto"> 3 - <button 4 - id="starBtn" 5 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 - {{ if .IsStarred }} 7 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 8 - {{ else }} 9 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 10 - {{ end }} 11 - 12 - hx-trigger="click" 13 - hx-target="#starBtn" 14 - hx-swap="outerHTML" 15 - hx-disabled-elt="#starBtn" 16 - > 17 - {{ if .IsStarred }} 18 - {{ i "star" "w-4 h-4 fill-current" }} 19 - {{ else }} 20 - {{ i "star" "w-4 h-4" }} 21 - {{ end }} 22 - <span class="text-sm"> 23 - {{ .Stats.StarCount }} 24 - </span> 25 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 26 - </button> 27 - {{ if .DisableFork }} 28 - <button 29 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 30 - disabled 31 - title="Empty repositories cannot be forked" 32 - > 33 - {{ i "git-fork" "w-4 h-4" }} 34 - fork 35 - </button> 36 - {{ else }} 37 - <a 38 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 39 - hx-boost="true" 40 - href="/{{ .FullName }}/fork" 41 - > 42 - {{ i "git-fork" "w-4 h-4" }} 43 - fork 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </a> 46 - {{ end }} 47 - </div> 48 - {{ end }}
···
+26
appview/pages/templates/repo/fragments/repoStar.html
···
··· 1 + {{ define "repo/fragments/repoStar" }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + {{ if .IsStarred }} 6 + hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 + {{ else }} 8 + hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 + {{ end }} 10 + 11 + hx-trigger="click" 12 + hx-target="this" 13 + hx-swap="outerHTML" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .Stats.StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+61
appview/pages/templates/repo/fragments/splitDiff.html
···
··· 1 + {{ define "repo/fragments/splitDiff" }} 2 + {{ $name := .Id }} 3 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 + {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 + {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 + <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 14 + {{- range .LeftLines -}} 15 + {{- if .IsEmpty -}} 16 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 + </div> 21 + {{- else if eq .Op.String "-" -}} 22 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 + <div class="px-2">{{ .Content }}</div> 26 + </div> 27 + {{- else if eq .Op.String " " -}} 28 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 + <div class="px-2">{{ .Content }}</div> 32 + </div> 33 + {{- end -}} 34 + {{- end -}} 35 + {{- end -}}</div></div></pre> 36 + 37 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 38 + {{- range .RightLines -}} 39 + {{- if .IsEmpty -}} 40 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 + </div> 45 + {{- else if eq .Op.String "+" -}} 46 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 + <div class="px-2" >{{ .Content }}</div> 50 + </div> 51 + {{- else if eq .Op.String " " -}} 52 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 + <div class="px-2">{{ .Content }}</div> 56 + </div> 57 + {{- end -}} 58 + {{- end -}} 59 + {{- end -}}</div></div></pre> 60 + </div> 61 + {{ end }}
+19
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 + {{ define "repo/fragments/time" }} 6 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 + {{ 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 }}
+47
appview/pages/templates/repo/fragments/unifiedDiff.html
···
··· 1 + {{ define "repo/fragments/unifiedDiff" }} 2 + {{ $name := .Id }} 3 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 4 + {{- $oldStart := .OldPosition -}} 5 + {{- $newStart := .NewPosition -}} 6 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 + {{- $lineNrSepStyle1 := "" -}} 9 + {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 + {{- range .Lines -}} 16 + {{- if eq .Op.String "+" -}} 17 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 + <div class="px-2">{{ .Line }}</div> 22 + </div> 23 + {{- $newStart = add64 $newStart 1 -}} 24 + {{- end -}} 25 + {{- if eq .Op.String "-" -}} 26 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 + <div class="px-2">{{ .Line }}</div> 31 + </div> 32 + {{- $oldStart = add64 $oldStart 1 -}} 33 + {{- end -}} 34 + {{- if eq .Op.String " " -}} 35 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 + <div class="px-2">{{ .Line }}</div> 40 + </div> 41 + {{- $newStart = add64 $newStart 1 -}} 42 + {{- $oldStart = add64 $oldStart 1 -}} 43 + {{- end -}} 44 + {{- end -}} 45 + {{- end -}}</div></div></pre> 46 + {{ end }} 47 +
+43 -79
appview/pages/templates/repo/index.html
··· 127 {{ end }} 128 129 {{ define "fileTree" }} 130 - <div 131 - id="file-tree" 132 - class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" 133 - > 134 - {{ $containerstyle := "py-1" }} 135 - {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 136 137 - {{ range .Files }} 138 - {{ if not .IsFile }} 139 - <div class="{{ $containerstyle }}"> 140 - <div class="flex justify-between items-center"> 141 - <a 142 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 143 - class="{{ $linkstyle }}" 144 - > 145 - <div class="flex items-center gap-2"> 146 - {{ i "folder" "size-4 fill-current" }} 147 - {{ .Name }} 148 - </div> 149 - </a> 150 151 - {{ if .LastCommit }} 152 - <time class="text-xs text-gray-500 dark:text-gray-400" 153 - >{{ timeFmt .LastCommit.When }}</time 154 - > 155 - {{ end }} 156 - </div> 157 - </div> 158 - {{ end }} 159 - {{ end }} 160 - 161 - {{ range .Files }} 162 - {{ if .IsFile }} 163 - <div class="{{ $containerstyle }}"> 164 - <div class="flex justify-between items-center"> 165 - <a 166 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 167 - class="{{ $linkstyle }}" 168 - > 169 - <div class="flex items-center gap-2"> 170 - {{ i "file" "size-4" }}{{ .Name }} 171 - </div> 172 - </a> 173 174 - {{ if .LastCommit }} 175 - <time class="text-xs text-gray-500 dark:text-gray-400" 176 - >{{ timeFmt .LastCommit.When }}</time 177 - > 178 - {{ end }} 179 - </div> 180 - </div> 181 - {{ end }} 182 - {{ end }} 183 - </div> 184 {{ end }} 185 186 {{ define "rightInfo" }} ··· 194 {{ define "commitLog" }} 195 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 196 <div class="flex justify-between items-center"> 197 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 198 - <div class="flex gap-2 items-center font-bold"> 199 - {{ i "logs" "w-4 h-4" }} commits 200 - </div> 201 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 202 - view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 203 - </span> 204 </a> 205 </div> 206 <div class="flex flex-col gap-6"> ··· 266 {{ end }}" 267 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 268 >{{ if $didOrHandle }} 269 - {{ $didOrHandle }} 270 {{ else }} 271 {{ .Author.Name }} 272 {{ end }}</a 273 > 274 </span> 275 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 276 - <span>{{ timeFmt .Committer.When }}</span> 277 278 <!-- tags/branches --> 279 {{ $tagsForCommit := index $.TagMap .Hash.String }} ··· 302 {{ define "branchList" }} 303 {{ if gt (len .BranchesTrunc) 0 }} 304 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 305 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 306 - <div class="flex gap-2 items-center font-bold"> 307 - {{ i "git-branch" "w-4 h-4" }} branches 308 - </div> 309 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 310 - view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 311 - </span> 312 </a> 313 <div class="flex flex-col gap-1"> 314 {{ range .BranchesTrunc }} ··· 320 </a> 321 {{ if .Commit }} 322 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 323 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time> 324 {{ end }} 325 {{ if .IsDefault }} 326 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> ··· 345 {{ if gt (len .TagsTrunc) 0 }} 346 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 347 <div class="flex justify-between items-center"> 348 - <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 349 - <div class="flex gap-2 items-center font-bold"> 350 - {{ i "tags" "w-4 h-4" }} tags 351 - </div> 352 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 353 - view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 354 - </span> 355 </a> 356 </div> 357 <div class="flex flex-col gap-1"> ··· 366 </div> 367 <div> 368 {{ with .Tag }} 369 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time> 370 {{ end }} 371 {{ if eq $idx 0 }} 372 {{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }} ··· 382 {{ end }} 383 384 {{ define "repoAfter" }} 385 - {{- if .HTMLReadme -}} 386 <section 387 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 }} 388 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 390 dark:[&_pre]:border dark:[&_pre]:border-gray-700 391 {{ end }}" 392 > 393 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll"> 394 - {{- .HTMLReadme -}} 395 </pre> 396 {{- else -}} 397 {{ .HTMLReadme }}
··· 127 {{ end }} 128 129 {{ define "fileTree" }} 130 + <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" > 131 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 133 + {{ range .Files }} 134 + <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 + <div class="col-span-1"> 136 + {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 + {{ $icon := "folder" }} 138 + {{ $iconStyle := "size-4 fill-current" }} 139 140 + {{ if .IsFile }} 141 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 142 + {{ $icon = "file" }} 143 + {{ $iconStyle = "size-4" }} 144 + {{ end }} 145 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 + <div class="flex items-center gap-2"> 147 + {{ i $icon $iconStyle }}{{ .Name }} 148 + </div> 149 + </a> 150 + </div> 151 152 + <div class="text-xs col-span-1 text-right"> 153 + {{ with .LastCommit }} 154 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 + {{ end }} 156 + </div> 157 + </div> 158 + {{ end }} 159 + </div> 160 {{ end }} 161 162 {{ define "rightInfo" }} ··· 170 {{ define "commitLog" }} 171 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 172 <div class="flex justify-between items-center"> 173 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 174 + {{ i "logs" "w-4 h-4" }} commits 175 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 176 </a> 177 </div> 178 <div class="flex flex-col gap-6"> ··· 238 {{ end }}" 239 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 240 >{{ if $didOrHandle }} 241 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 242 {{ else }} 243 {{ .Author.Name }} 244 {{ end }}</a 245 > 246 </span> 247 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 248 + {{ template "repo/fragments/time" .Committer.When }} 249 250 <!-- tags/branches --> 251 {{ $tagsForCommit := index $.TagMap .Hash.String }} ··· 274 {{ define "branchList" }} 275 {{ if gt (len .BranchesTrunc) 0 }} 276 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 277 + <a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 278 + {{ i "git-branch" "w-4 h-4" }} branches 279 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 280 </a> 281 <div class="flex flex-col gap-1"> 282 {{ range .BranchesTrunc }} ··· 288 </a> 289 {{ 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> 292 {{ end }} 293 {{ if .IsDefault }} 294 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> ··· 313 {{ if gt (len .TagsTrunc) 0 }} 314 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 315 <div class="flex justify-between items-center"> 316 + <a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 317 + {{ i "tags" "w-4 h-4" }} tags 318 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 319 </a> 320 </div> 321 <div class="flex flex-col gap-1"> ··· 330 </div> 331 <div> 332 {{ with .Tag }} 333 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span> 334 {{ end }} 335 {{ if eq $idx 0 }} 336 {{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }} ··· 346 {{ end }} 347 348 {{ define "repoAfter" }} 349 + {{- 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 ··· 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 }}
+3 -5
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 {{ with .Comment }} 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 ··· 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 {{ if $isIssueAuthor }} 11 <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 author 14 - </span> 15 {{ end }} 16 17 <span class="before:content-['ยท']"></span> 18 <a 19 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 id="{{ .CommentId }}"> 22 - {{ .Created | timeFmt }} 23 </a> 24 25 <button
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 {{ with .Comment }} 3 <div id="comment-container-{{.CommentId}}"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 ··· 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 {{ if $isIssueAuthor }} 11 <span class="before:content-['ยท']"></span> 12 author 13 {{ end }} 14 15 <span class="before:content-['ยท']"></span> 16 <a 17 href="#{{ .CommentId }}" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 id="{{ .CommentId }}"> 20 + {{ template "repo/fragments/time" .Created }} 21 </a> 22 23 <button
+15 -16
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 {{ define "repo/issues/fragments/issueComment" }} 2 {{ with .Comment }} 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm"> 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 8 <span class="before:content-['ยท']"></span> 9 <a ··· 11 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 12 id="{{ .CommentId }}"> 13 {{ if .Deleted }} 14 - deleted {{ .Deleted | timeFmt }} 15 {{ else if .Edited }} 16 - edited {{ .Edited | timeFmt }} 17 {{ else }} 18 - {{ .Created | timeFmt }} 19 {{ end }} 20 </a> 21 - 22 - <!-- show user "hats" --> 23 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 - {{ if $isIssueAuthor }} 25 - <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"> 26 - author 27 - </span> 28 - {{ end }} 29 30 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 {{ if and $isCommentOwner (not .Deleted) }} 32 - <button 33 - class="btn px-2 py-1 text-sm" 34 hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 35 hx-swap="outerHTML" 36 hx-target="#comment-container-{{.CommentId}}" 37 > 38 {{ i "pencil" "w-4 h-4" }} 39 </button> 40 - <button 41 class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 42 hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 43 hx-confirm="Are you sure you want to delete your comment?"
··· 1 {{ define "repo/issues/fragments/issueComment" }} 2 {{ with .Comment }} 3 <div id="comment-container-{{.CommentId}}"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 + {{ template "user/fragments/picHandleLink" $owner }} 7 + 8 + <!-- show user "hats" --> 9 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 + {{ if $isIssueAuthor }} 11 + <span class="before:content-['ยท']"></span> 12 + author 13 + {{ end }} 14 15 <span class="before:content-['ยท']"></span> 16 <a ··· 18 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 id="{{ .CommentId }}"> 20 {{ if .Deleted }} 21 + deleted {{ template "repo/fragments/time" .Deleted }} 22 {{ else if .Edited }} 23 + edited {{ template "repo/fragments/time" .Edited }} 24 {{ else }} 25 + {{ template "repo/fragments/time" .Created }} 26 {{ end }} 27 </a> 28 29 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 30 {{ if and $isCommentOwner (not .Deleted) }} 31 + <button 32 + class="btn px-2 py-1 text-sm" 33 hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 34 hx-swap="outerHTML" 35 hx-target="#comment-container-{{.CommentId}}" 36 > 37 {{ i "pencil" "w-4 h-4" }} 38 </button> 39 + <button 40 class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 41 hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 42 hx-confirm="Are you sure you want to delete your comment?"
+17 -5
appview/pages/templates/repo/issues/issue.html
··· 33 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 opened by 35 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandle" $owner }} 37 <span class="select-none before:content-['\00B7']"></span> 38 - <time title="{{ .Issue.Created | longTimeFmt }}"> 39 - {{ .Issue.Created | timeFmt }} 40 - </time> 41 </span> 42 </div> 43 ··· 46 {{ .Issue.Body | markdown }} 47 </article> 48 {{ end }} 49 </section> 50 {{ end }} 51 ··· 76 > 77 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 78 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 79 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 80 </div> 81 <textarea 82 id="comment-textarea"
··· 33 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 opened by 35 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 + {{ template "user/fragments/picHandleLink" $owner }} 37 <span class="select-none before:content-['\00B7']"></span> 38 + {{ template "repo/fragments/time" .Issue.Created }} 39 </span> 40 </div> 41 ··· 44 {{ .Issue.Body | markdown }} 45 </article> 46 {{ end }} 47 + 48 + <div class="flex items-center gap-2 mt-2"> 49 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 + {{ range $kind := .OrderedReactionKinds }} 51 + {{ 52 + template "repo/fragments/reaction" 53 + (dict 54 + "Kind" $kind 55 + "Count" (index $.Reactions $kind) 56 + "IsReacted" (index $.UserReacted $kind) 57 + "ThreadAt" $.Issue.IssueAt) 58 + }} 59 + {{ end }} 60 + </div> 61 </section> 62 {{ end }} 63 ··· 88 > 89 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 90 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 91 + {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 92 </div> 93 <textarea 94 id="comment-textarea"
+2 -4
appview/pages/templates/repo/issues/issues.html
··· 66 67 <span class="ml-1"> 68 {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandle" $owner }} 70 </span> 71 72 <span class="before:content-['ยท']"> 73 - <time> 74 - {{ .Created | timeFmt }} 75 - </time> 76 </span> 77 78 <span class="before:content-['ยท']">
··· 66 67 <span class="ml-1"> 68 {{ $owner := index $.DidHandleMap .OwnerDid }} 69 + {{ template "user/fragments/picHandleLink" $owner }} 70 </span> 71 72 <span class="before:content-['ยท']"> 73 + {{ template "repo/fragments/time" .Created }} 74 </span> 75 76 <span class="before:content-['ยท']">
+76 -79
appview/pages/templates/repo/log.html
··· 14 </h2> 15 16 <!-- desktop view (hidden on small screens) --> 17 - <table class="w-full border-collapse hidden md:table"> 18 - <thead> 19 - <tr> 20 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th> 21 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th> 22 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th> 23 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th> 24 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th> 25 - </tr> 26 - </thead> 27 - <tbody> 28 - {{ range $index, $commit := .Commits }} 29 - {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 30 - <tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 31 - <td class=" py-3 align-top"> 32 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 33 - {{ if $didOrHandle }} 34 - <a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a> 35 - {{ else }} 36 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 37 - {{ end }} 38 - </td> 39 - <td class="py-3 align-top font-mono flex items-center"> 40 - {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 41 - {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 42 - {{ if $verified }} 43 - {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 44 - {{ end }} 45 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 46 - {{ slice $commit.Hash.String 0 8 }} 47 - {{ if $verified }} 48 - {{ i "shield-check" "w-4 h-4" }} 49 - {{ end }} 50 - </a> 51 - <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 52 - <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 53 - title="Copy SHA" 54 - onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 55 - {{ i "copy" "w-4 h-4" }} 56 - </button> 57 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 58 - {{ i "folder-code" "w-4 h-4" }} 59 - </a> 60 - </div> 61 62 - </td> 63 - <td class=" py-3 align-top"> 64 - <div class="flex items-center justify-start gap-2"> 65 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 66 - {{ if gt (len $messageParts) 1 }} 67 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 68 - {{ end }} 69 70 - {{ if index $.TagMap $commit.Hash.String }} 71 - {{ range $tag := index $.TagMap $commit.Hash.String }} 72 - <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 73 - {{ $tag }} 74 - </span> 75 - {{ end }} 76 - {{ end }} 77 - </div> 78 79 - {{ if gt (len $messageParts) 1 }} 80 - <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 81 - {{ end }} 82 - </td> 83 - <td class="py-3 align-top"> 84 - <!-- ci status --> 85 - {{ $pipeline := index $.Pipelines .Hash.String }} 86 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 87 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 88 - {{ end }} 89 - </td> 90 - <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td> 91 - </tr> 92 - {{ end }} 93 - </tbody> 94 - </table> 95 96 <!-- mobile view (visible only on small screens) --> 97 <div class="md:hidden"> ··· 102 <div class="text-base cursor-pointer"> 103 <div class="flex items-center justify-between"> 104 <div class="flex-1"> 105 - <div class="inline-flex items-end"> 106 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 107 class="inline no-underline hover:underline dark:text-white"> 108 {{ index $messageParts 0 }} 109 </a> 110 {{ if gt (len $messageParts) 1 }} 111 <button 112 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2" 113 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 114 {{ i "ellipsis" "w-3 h-3" }} 115 </button> ··· 159 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 160 <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 161 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 162 - {{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 163 </a> 164 </span> 165 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 166 - <span>{{ shortTimeFmt $commit.Committer.When }}</span> 167 168 <!-- ci status --> 169 {{ $pipeline := index $.Pipelines .Hash.String }}
··· 14 </h2> 15 16 <!-- desktop view (hidden on small screens) --> 17 + <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 + {{ $grid := "grid grid-cols-14 gap-4" }} 19 + <div class="{{ $grid }}"> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 21 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 + </div> 26 + {{ range $index, $commit := .Commits }} 27 + {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 + <div class="{{ $grid }} py-3"> 29 + <div class="align-top truncate col-span-2"> 30 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 + {{ if $didOrHandle }} 32 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 + {{ else }} 34 + <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 + {{ end }} 36 + </div> 37 + <div class="align-top font-mono flex items-start col-span-3"> 38 + {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 39 + {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 40 + {{ if $verified }} 41 + {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 42 + {{ end }} 43 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 44 + {{ slice $commit.Hash.String 0 8 }} 45 + {{ if $verified }} 46 + {{ i "shield-check" "w-4 h-4" }} 47 + {{ end }} 48 + </a> 49 + <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 50 + <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 51 + title="Copy SHA" 52 + onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 53 + {{ i "copy" "w-4 h-4" }} 54 + </button> 55 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 56 + {{ i "folder-code" "w-4 h-4" }} 57 + </a> 58 + </div> 59 60 + </div> 61 + <div class="align-top col-span-6"> 62 + <div> 63 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 64 + {{ if gt (len $messageParts) 1 }} 65 + <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 + {{ end }} 67 68 + {{ if index $.TagMap $commit.Hash.String }} 69 + {{ range $tag := index $.TagMap $commit.Hash.String }} 70 + <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 71 + {{ $tag }} 72 + </span> 73 + {{ end }} 74 + {{ end }} 75 + </div> 76 77 + {{ if gt (len $messageParts) 1 }} 78 + <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 + {{ end }} 80 + </div> 81 + <div class="align-top col-span-1"> 82 + <!-- ci status --> 83 + {{ $pipeline := index $.Pipelines .Hash.String }} 84 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 + {{ end }} 87 + </div> 88 + <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 + </div> 90 + {{ end }} 91 + </div> 92 93 <!-- mobile view (visible only on small screens) --> 94 <div class="md:hidden"> ··· 99 <div class="text-base cursor-pointer"> 100 <div class="flex items-center justify-between"> 101 <div class="flex-1"> 102 + <div> 103 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 104 class="inline no-underline hover:underline dark:text-white"> 105 {{ index $messageParts 0 }} 106 </a> 107 {{ if gt (len $messageParts) 1 }} 108 <button 109 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 110 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 111 {{ i "ellipsis" "w-3 h-3" }} 112 </button> ··· 156 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 + {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 </a> 161 </span> 162 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 163 + <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 164 165 <!-- ci status --> 166 {{ $pipeline := index $.Pipelines .Hash.String }}
+3 -3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 1 {{ define "repo/pipelines/fragments/logBlock" }} 2 <div id="lines" hx-swap-oob="beforeend"> 3 - <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group bg-gray-100 pb-2 px-2 dark:bg-gray-900"> 4 - <summary class="sticky top-0 pt-2 group-open:pb-2 list-none cursor-pointer bg-gray-100 dark:bg-gray-900 hover:text-gray-500 hover:dark:text-gray-400"> 5 <div class="group-open:hidden flex items-center gap-1"> 6 {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 </div> ··· 9 {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 </div> 11 </summary> 12 - <div class="font-mono whitespace-pre overflow-x-auto"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 </details> 14 </div> 15 {{ end }}
··· 1 {{ define "repo/pipelines/fragments/logBlock" }} 2 <div id="lines" hx-swap-oob="beforeend"> 3 + <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 + <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 <div class="group-open:hidden flex items-center gap-1"> 6 {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 </div> ··· 9 {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 </div> 11 </summary> 12 + <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 </details> 14 </div> 15 {{ end }}
+5 -9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
··· 10 {{ $lastStatus := $all.Latest }} 11 {{ $kind := $lastStatus.Status.String }} 12 13 - {{ $t := .TimeTaken }} 14 - {{ $time := "" }} 15 - {{ if $t }} 16 - {{ $time = durationFmt $t }} 17 - {{ else }} 18 - {{ $time = printf "%s ago" (shortTimeFmt $pipeline.Created) }} 19 - {{ end }} 20 - 21 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 22 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 23 {{ $name }} 24 </div> 25 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 26 <span class="font-bold">{{ $kind }}</span> 27 - <time>{{ $time }}</time> 28 </div> 29 </div> 30 </a>
··· 10 {{ $lastStatus := $all.Latest }} 11 {{ $kind := $lastStatus.Status.String }} 12 13 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 14 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 15 {{ $name }} 16 </div> 17 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 18 <span class="font-bold">{{ $kind }}</span> 19 + {{ if .TimeTaken }} 20 + {{ template "repo/fragments/duration" .TimeTaken }} 21 + {{ else }} 22 + {{ template "repo/fragments/shortTimeAgo" $pipeline.Created }} 23 + {{ end }} 24 </div> 25 </div> 26 </a>
+1 -3
appview/pages/templates/repo/pipelines/pipelines.html
··· 74 </div> 75 76 <div class="text-sm md:text-base col-span-1 text-right"> 77 - <time title="{{ .Created | longTimeFmt }}"> 78 - {{ .Created | shortTimeFmt }} ago 79 - </time> 80 </div> 81 82 {{ $t := .TimeTaken }}
··· 74 </div> 75 76 <div class="text-sm md:text-base col-span-1 text-right"> 77 + {{ template "repo/fragments/shortTimeAgo" .Created }} 78 </div> 79 80 {{ $t := .TimeTaken }}
+5 -13
appview/pages/templates/repo/pipelines/workflow.html
··· 17 </section> 18 {{ end }} 19 20 - {{ define "repoAfter" }} 21 - {{ end }} 22 - 23 {{ define "sidebar" }} 24 {{ $active := .Workflow }} 25 {{ with .Pipeline }} ··· 32 {{ $lastStatus := $all.Latest }} 33 {{ $kind := $lastStatus.Status.String }} 34 35 - {{ $t := .TimeTaken }} 36 - {{ $time := "" }} 37 - 38 - {{ if $t }} 39 - {{ $time = durationFmt $t }} 40 - {{ else }} 41 - {{ $time = printf "%s ago" (shortTimeFmt $lastStatus.Created) }} 42 - {{ end }} 43 - 44 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 45 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 46 {{ $name }} 47 </div> 48 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 49 <span class="font-bold">{{ $kind }}</span> 50 - <time>{{ $time }}</time> 51 </div> 52 </div> 53 </a>
··· 17 </section> 18 {{ end }} 19 20 {{ define "sidebar" }} 21 {{ $active := .Workflow }} 22 {{ with .Pipeline }} ··· 29 {{ $lastStatus := $all.Latest }} 30 {{ $kind := $lastStatus.Status.String }} 31 32 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 33 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 34 {{ $name }} 35 </div> 36 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 37 <span class="font-bold">{{ $kind }}</span> 38 + {{ if .TimeTaken }} 39 + {{ template "repo/fragments/duration" .TimeTaken }} 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 42 + {{ end }} 43 </div> 44 </div> 45 </a>
+18 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 opened by 31 {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandle" $owner }} 33 <span class="select-none before:content-['\00B7']"></span> 34 - <time>{{ .Pull.Created | timeFmt }}</time> 35 36 <span class="select-none before:content-['\00B7']"></span> 37 <span> ··· 60 <article id="body" class="mt-8 prose dark:prose-invert"> 61 {{ .Pull.Body | markdown }} 62 </article> 63 {{ end }} 64 </section> 65
··· 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 opened by 31 {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 + {{ template "user/fragments/picHandleLink" $owner }} 33 <span class="select-none before:content-['\00B7']"></span> 34 + {{ template "repo/fragments/time" .Pull.Created }} 35 36 <span class="select-none before:content-['\00B7']"></span> 37 <span> ··· 60 <article id="body" class="mt-8 prose dark:prose-invert"> 61 {{ .Pull.Body | markdown }} 62 </article> 63 + {{ end }} 64 + 65 + {{ with .OrderedReactionKinds }} 66 + <div class="flex items-center gap-2 mt-2"> 67 + {{ template "repo/fragments/reactionsPopUp" . }} 68 + {{ range $kind := . }} 69 + {{ 70 + template "repo/fragments/reaction" 71 + (dict 72 + "Kind" $kind 73 + "Count" (index $.Reactions $kind) 74 + "IsReacted" (index $.UserReacted $kind) 75 + "ThreadAt" $.Pull.PullAt) 76 + }} 77 + {{ end }} 78 + </div> 79 {{ end }} 80 </section> 81
+2 -3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} ··· 38 </form> 39 </div> 40 {{ end }} 41 -
··· 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} ··· 38 </form> 39 </div> 40 {{ end }}
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 1 {{ define "repo/pulls/fragments/pullStack" }} 2 - 3 <details class="bg-white dark:bg-gray-800 group" open> 4 <summary class="p-2 text-sm font-bold list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 5 <span class="flex items-center gap-2">
··· 1 {{ define "repo/pulls/fragments/pullStack" }} 2 <details class="bg-white dark:bg-gray-800 group" open> 3 <summary class="p-2 text-sm font-bold list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 4 <span class="flex items-center gap-2">
+6 -8
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 13 </span> 14 </div> 15 16 - <div class="flex-shrink-0 flex items-center"> 17 {{ $latestRound := .LastRoundNumber }} 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 {{ $commentCount := len $lastSubmission.Comments }} 20 - {{ if $pipeline }} 21 - <div class="inline-flex items-center gap-2"> 22 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 23 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 24 - </div> 25 {{ end }} 26 <span> 27 - <div class="inline-flex items-center gap-2"> 28 {{ i "message-square" "w-3 h-3 md:hidden" }} 29 {{ $commentCount }} 30 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 31 </div> 32 </span> 33 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 34 <span> 35 <span class="hidden md:inline">round</span> 36 <span class="font-mono">#{{ $latestRound }}</span>
··· 13 </span> 14 </div> 15 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 {{ $latestRound := .LastRoundNumber }} 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 {{ $commentCount := len $lastSubmission.Comments }} 20 + {{ if and $pipeline $pipeline.Id }} 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 {{ end }} 24 <span> 25 + <div class="inline-flex items-center gap-1"> 26 {{ i "message-square" "w-3 h-3 md:hidden" }} 27 {{ $commentCount }} 28 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 29 </div> 30 </span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 32 <span> 33 <span class="hidden md:inline">round</span> 34 <span class="font-mono">#{{ $latestRound }}</span>
+44 -3
appview/pages/templates/repo/pulls/interdiff.html
··· 26 </header> 27 </section> 28 29 - <section> 30 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 31 - </section> 32 {{ end }} 33
··· 26 </header> 27 </section> 28 29 + {{ end }} 30 + 31 + {{ define "topbarLayout" }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/topbar" . }} 34 + </header> 35 + {{ end }} 36 + 37 + {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 + {{ block "contentLayout" . }} 40 + {{ block "content" . }}{{ end }} 41 + {{ end }} 42 + 43 + {{ block "contentAfterLayout" . }} 44 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-10"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 51 + </div> 52 + {{ end }} 53 + </div> 54 {{ end }} 55 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/footer" . }} 59 + </footer> 60 + {{ end }} 61 + 62 + 63 + {{ define "contentAfter" }} 64 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 65 + {{end}} 66 + 67 + {{ define "contentAfterLeft" }} 68 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 + </div> 71 + <div class="sticky top-0 flex-grow max-h-screen"> 72 + {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 + </div> 74 + {{end}}
+44 -1
appview/pages/templates/repo/pulls/patch.html
··· 31 <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 32 {{ template "repo/pulls/fragments/pullHeader" . }} 33 </section> 34 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 35 </section> 36 {{ end }}
··· 31 <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 32 {{ template "repo/pulls/fragments/pullHeader" . }} 33 </section> 34 </section> 35 {{ end }} 36 + 37 + {{ define "topbarLayout" }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/topbar" . }} 40 + </header> 41 + {{ end }} 42 + 43 + {{ define "mainLayout" }} 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + {{ block "content" . }}{{ end }} 47 + {{ end }} 48 + 49 + {{ block "contentAfterLayout" . }} 50 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 51 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 52 + {{ block "contentAfterLeft" . }} {{ end }} 53 + </div> 54 + <main class="col-span-1 md:col-span-10"> 55 + {{ block "contentAfter" . }}{{ end }} 56 + </main> 57 + </div> 58 + {{ end }} 59 + </div> 60 + {{ end }} 61 + 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/footer" . }} 65 + </footer> 66 + {{ end }} 67 + 68 + {{ define "contentAfter" }} 69 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 70 + {{end}} 71 + 72 + {{ define "contentAfterLeft" }} 73 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 + </div> 76 + <div class="sticky top-0 flex-grow max-h-screen"> 77 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 + </div> 79 + {{end}}
+14 -20
appview/pages/templates/repo/pulls/pull.html
··· 5 {{ define "extrameta" }} 6 {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 {{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 ··· 46 </div> 47 <!-- round summary --> 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 - <span> 50 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 51 {{ $re := "re" }} 52 {{ if eq .RoundNumber 0 }} 53 {{ $re = "" }} 54 {{ end }} 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by <a href="/{{ $owner }}">{{ $owner }}</a> 57 <span class="select-none before:content-['\00B7']"></span> 58 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a> 59 <span class="select-none before:content-['ยท']"></span> 60 {{ $s := "s" }} 61 {{ if eq (len .Comments) 1 }} ··· 68 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 69 hx-boost="true" 70 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 71 - {{ i "file-diff" "w-4 h-4" }} 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </a> ··· 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} 153 - <div class="text-sm text-gray-500 dark:text-gray-400"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - <a href="/{{$owner}}">{{$owner}}</a> 156 <span class="before:content-['ยท']"></span> 157 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a> 158 </div> 159 <div class="prose dark:prose-invert"> 160 {{ $c.Body | markdown }} ··· 179 {{ end }} 180 </div> 181 </details> 182 - <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 183 {{ end }} 184 {{ end }} 185 {{ end }} ··· 277 {{ $lastStatus := $all.Latest }} 278 {{ $kind := $lastStatus.Status.String }} 279 280 - {{ $t := .TimeTaken }} 281 - {{ $time := "" }} 282 - 283 - {{ if $t }} 284 - {{ $time = durationFmt $t }} 285 - {{ else }} 286 - {{ $time = printf "%s ago" (shortTimeFmt $lastStatus.Created) }} 287 - {{ end }} 288 - 289 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 290 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 291 {{ $name }} 292 </div> 293 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 294 <span class="font-bold">{{ $kind }}</span> 295 - <time>{{ $time }}</time> 296 </div> 297 </div> 298 </a>
··· 5 {{ define "extrameta" }} 6 {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 {{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 + 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 ··· 46 </div> 47 <!-- round summary --> 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 + <span class="gap-1 flex items-center"> 50 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 51 {{ $re := "re" }} 52 {{ if eq .RoundNumber 0 }} 53 {{ $re = "" }} 54 {{ end }} 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 + by {{ template "user/fragments/picHandleLink" $owner }} 57 <span class="select-none before:content-['\00B7']"></span> 58 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 <span class="select-none before:content-['ยท']"></span> 60 {{ $s := "s" }} 61 {{ if eq (len .Comments) 1 }} ··· 68 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 69 hx-boost="true" 70 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 71 + {{ i "file-diff" "w-4 h-4" }} 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </a> ··· 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} 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 }} 156 <span class="before:content-['ยท']"></span> 157 + <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 </div> 159 <div class="prose dark:prose-invert"> 160 {{ $c.Body | markdown }} ··· 179 {{ end }} 180 </div> 181 </details> 182 {{ end }} 183 {{ end }} 184 {{ end }} ··· 276 {{ $lastStatus := $all.Latest }} 277 {{ $kind := $lastStatus.Status.String }} 278 279 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 280 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 281 {{ $name }} 282 </div> 283 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 284 <span class="font-bold">{{ $kind }}</span> 285 + {{ if .TimeTaken }} 286 + {{ template "repo/fragments/duration" .TimeTaken }} 287 + {{ else }} 288 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 289 + {{ end }} 290 </div> 291 </div> 292 </a>
+46 -57
appview/pages/templates/repo/pulls/pulls.html
··· 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 {{ $owner := index $.DidHandleMap .OwnerDid }} 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 {{ $icon := "ban" }} ··· 76 </span> 77 78 <span class="ml-1"> 79 - {{ template "user/fragments/picHandle" $owner }} 80 </span> 81 82 - <span> 83 - <time> 84 - {{ .Created | timeFmt }} 85 - </time> 86 </span> 87 88 <span class="before:content-['ยท']"> 89 - targeting 90 - <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"> 91 - {{ .TargetBranch }} 92 - </span> 93 </span> 94 - {{ if not .IsPatchBased }} 95 - from 96 - <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"> 97 - {{ if .IsForkBased }} 98 - {{ if .PullSource.Repo }} 99 - <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 100 - {{- else -}} 101 - <span class="italic">[deleted fork]</span> 102 - {{- end -}} 103 - {{- end -}} 104 - {{- .PullSource.Branch -}} 105 </span> 106 {{ end }} 107 - <span class="before:content-['ยท']"> 108 - {{ $latestRound := .LastRoundNumber }} 109 - {{ $lastSubmission := index .Submissions $latestRound }} 110 - round 111 - <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"> 112 - #{{ .LastRoundNumber }} 113 - </span> 114 - {{ $commentCount := len $lastSubmission.Comments }} 115 - {{ $s := "s" }} 116 - {{ if eq $commentCount 1 }} 117 - {{ $s = "" }} 118 - {{ end }} 119 - 120 - {{ if eq $commentCount 0 }} 121 - awaiting comments 122 - {{ else }} 123 - recieved {{ len $lastSubmission.Comments}} comment{{$s}} 124 - {{ end }} 125 - </span> 126 - </p> 127 </div> 128 {{ if .StackId }} 129 {{ $otherPulls := index $.Stacks .StackId }} 130 - <details class="bg-white dark:bg-gray-800 group"> 131 - <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 132 - {{ $s := "s" }} 133 - {{ if eq (len $otherPulls) 1 }} 134 - {{ $s = "" }} 135 - {{ end }} 136 - <div class="group-open:hidden flex items-center gap-2"> 137 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 138 - </div> 139 - <div class="hidden group-open:flex items-center gap-2"> 140 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 141 - </div> 142 - </summary> 143 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 144 - </details> 145 {{ end }} 146 </div> 147 {{ end }} ··· 153 {{ $root := index . 1 }} 154 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 155 {{ range $pull := $list }} 156 <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"> 157 <div class="flex gap-2 items-center px-6"> 158 <div class="flex-grow min-w-0 w-full py-2"> 159 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 160 </div> 161 </div> 162 </a>
··· 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div> 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 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 {{ $icon := "ban" }} ··· 76 </span> 77 78 <span class="ml-1"> 79 + {{ template "user/fragments/picHandleLink" $owner }} 80 </span> 81 82 + <span class="before:content-['ยท']"> 83 + {{ template "repo/fragments/time" .Created }} 84 </span> 85 + 86 + 87 + {{ $latestRound := .LastRoundNumber }} 88 + {{ $lastSubmission := index .Submissions $latestRound }} 89 90 <span class="before:content-['ยท']"> 91 + {{ $commentCount := len $lastSubmission.Comments }} 92 + {{ $s := "s" }} 93 + {{ if eq $commentCount 1 }} 94 + {{ $s = "" }} 95 + {{ end }} 96 + 97 + {{ len $lastSubmission.Comments}} comment{{$s}} 98 </span> 99 + 100 + <span class="before:content-['ยท']"> 101 + round 102 + <span class="font-mono"> 103 + #{{ .LastRoundNumber }} 104 + </span> 105 </span> 106 + 107 + {{ $pipeline := index $.Pipelines .LatestSha }} 108 + {{ if and $pipeline $pipeline.Id }} 109 + <span class="before:content-['ยท']"></span> 110 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 111 {{ end }} 112 + </div> 113 </div> 114 {{ if .StackId }} 115 {{ $otherPulls := index $.Stacks .StackId }} 116 + {{ if gt (len $otherPulls) 0 }} 117 + <details class="bg-white dark:bg-gray-800 group"> 118 + <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 119 + {{ $s := "s" }} 120 + {{ if eq (len $otherPulls) 1 }} 121 + {{ $s = "" }} 122 + {{ end }} 123 + <div class="group-open:hidden flex items-center gap-2"> 124 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 125 + </div> 126 + <div class="hidden group-open:flex items-center gap-2"> 127 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 128 + </div> 129 + </summary> 130 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 131 + </details> 132 + {{ end }} 133 {{ end }} 134 </div> 135 {{ end }} ··· 141 {{ $root := index . 1 }} 142 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 143 {{ range $pull := $list }} 144 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 145 <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 <div class="flex gap-2 items-center px-6"> 147 <div class="flex-grow min-w-0 w-full py-2"> 148 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 149 </div> 150 </div> 151 </a>
+110
appview/pages/templates/repo/settings/access.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "collaboratorSettings" . }} 10 + </div> 11 + </section> 12 + {{ end }} 13 + 14 + {{ define "collaboratorSettings" }} 15 + <div class="grid grid-cols-1 gap-4 items-center"> 16 + <div class="col-span-1"> 17 + <h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2> 18 + <p class="text-gray-500 dark:text-gray-400"> 19 + Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows. 20 + </p> 21 + </div> 22 + {{ template "collaboratorsGrid" . }} 23 + </div> 24 + {{ end }} 25 + 26 + {{ define "collaboratorsGrid" }} 27 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 28 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 29 + {{ template "addCollaboratorButton" . }} 30 + {{ end }} 31 + {{ range .Collaborators }} 32 + <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 + <div class="flex items-center gap-3"> 34 + <img 35 + src="{{ fullAvatar .Handle }}" 36 + alt="{{ .Handle }}" 37 + class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 + 39 + <div class="flex-1 min-w-0"> 40 + <a href="/{{ .Handle }}" class="block truncate"> 41 + {{ didOrHandle .Did .Handle }} 42 + </a> 43 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 + </div> 45 + </div> 46 + </div> 47 + {{ end }} 48 + </div> 49 + {{ end }} 50 + 51 + {{ define "addCollaboratorButton" }} 52 + <button 53 + class="btn block rounded p-4" 54 + popovertarget="add-collaborator-modal" 55 + popovertargetaction="toggle"> 56 + <div class="flex items-center gap-3"> 57 + <div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 58 + {{ i "user-plus" "size-4" }} 59 + </div> 60 + 61 + <div class="text-left flex-1 min-w-0 block truncate"> 62 + Add collaborator 63 + </div> 64 + </div> 65 + </button> 66 + <div 67 + id="add-collaborator-modal" 68 + popover 69 + 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"> 70 + {{ template "addCollaboratorModal" . }} 71 + </div> 72 + {{ end }} 73 + 74 + {{ define "addCollaboratorModal" }} 75 + <form 76 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 77 + hx-indicator="#spinner" 78 + hx-swap="none" 79 + class="flex flex-col gap-2" 80 + > 81 + <label for="add-collaborator" class="uppercase p-0"> 82 + ADD COLLABORATOR 83 + </label> 84 + <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 + <input 86 + type="text" 87 + id="add-collaborator" 88 + name="collaborator" 89 + required 90 + placeholder="@foo.bsky.social" 91 + /> 92 + <div class="flex gap-2 pt-2"> 93 + <button 94 + type="button" 95 + popovertarget="add-collaborator-modal" 96 + popovertargetaction="hide" 97 + 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" 98 + > 99 + {{ i "x" "size-4" }} cancel 100 + </button> 101 + <button type="submit" class="btn w-1/2 flex items-center"> 102 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 103 + <span id="spinner" class="group"> 104 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </span> 106 + </button> 107 + </div> 108 + <div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div> 109 + </form> 110 + {{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
···
··· 1 + {{ define "repo/settings/fragments/secretListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $secret := index . 1 }} 4 + <div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 6 + <span class="font-mono"> 7 + {{ $secret.Key }} 8 + </span> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 10 + <span>added by</span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 12 + <span class="before:content-['ยท'] before:select-none"></span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 14 + </div> 15 + </div> 16 + <button 17 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 18 + title="Delete secret" 19 + hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 20 + hx-swap="none" 21 + hx-vals='{"key": "{{ $secret.Key }}"}' 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 24 + {{ i "trash-2" "w-5 h-5" }} 25 + <span class="hidden md:inline">delete</span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + </div> 29 + {{ end }}
+16
appview/pages/templates/repo/settings/fragments/sidebar.html
···
··· 1 + {{ define "repo/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="/{{ $.RepoInfo.FullName }}/settings?tab={{.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 }}
+68
appview/pages/templates/repo/settings/general.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "branchSettings" . }} 10 + {{ template "deleteRepo" . }} 11 + </div> 12 + </section> 13 + {{ end }} 14 + 15 + {{ define "branchSettings" }} 16 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 17 + <div class="col-span-1 md:col-span-2"> 18 + <h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2> 19 + <p class="text-gray-500 dark:text-gray-400"> 20 + The default branch is considered the โ€œbaseโ€ branch in your repository, 21 + against which all pull requests and code commits are automatically made, 22 + unless you specify a different branch. 23 + </p> 24 + </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 + <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 + <option value="" disabled selected > 28 + Choose a default branch 29 + </option> 30 + {{ range .Branches }} 31 + <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 32 + {{ .Name }} 33 + </option> 34 + {{ end }} 35 + </select> 36 + <button class="btn flex gap-2 items-center" type="submit"> 37 + {{ i "check" "size-4" }} 38 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 + </button> 40 + </form> 41 + </div> 42 + {{ end }} 43 + 44 + {{ define "deleteRepo" }} 45 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 46 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 47 + <div class="col-span-1 md:col-span-2"> 48 + <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2> 49 + <p class="text-red-500 dark:text-red-400 "> 50 + Deleting a repository is irreversible and permanent. Be certain before deleting a repository. 51 + </p> 52 + </div> 53 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 54 + <button 55 + 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 + type="button" 57 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 + hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 + {{ i "trash-2" "size-4" }} 60 + delete 61 + <span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline"> 62 + {{ i "loader-circle" "w-4 h-4" }} 63 + </span> 64 + </button> 65 + </div> 66 + </div> 67 + {{ end }} 68 + {{ end }}
+140
appview/pages/templates/repo/settings/pipelines.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "spindleSettings" . }} 10 + {{ if $.CurrentSpindle }} 11 + {{ template "secretSettings" . }} 12 + {{ end }} 13 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 + </div> 15 + </section> 16 + {{ end }} 17 + 18 + {{ define "spindleSettings" }} 19 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 20 + <div class="col-span-1 md:col-span-2"> 21 + <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 + <p class="text-gray-500 dark:text-gray-400"> 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 26 + click to learn more. 27 + </a> 28 + </p> 29 + </div> 30 + {{ if not $.RepoInfo.Roles.IsOwner }} 31 + <div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 32 + {{ or $.CurrentSpindle "No spindle configured" }} 33 + </div> 34 + {{ else }} 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 + <select 37 + id="spindle" 38 + name="spindle" 39 + required 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> 42 + Choose a spindle 43 + </option> 44 + {{ range $.Spindles }} 45 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 46 + {{ . }} 47 + </option> 48 + {{ end }} 49 + </select> 50 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 51 + {{ i "check" "size-4" }} 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </button> 54 + </form> 55 + {{ end }} 56 + </div> 57 + {{ end }} 58 + 59 + {{ define "secretSettings" }} 60 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 61 + <div class="col-span-1 md:col-span-2"> 62 + <h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2> 63 + <p class="text-gray-500 dark:text-gray-400"> 64 + Secrets are accessible in workflow runs via environment variables. Anyone 65 + with collaborator access to this repository can add and use secrets in 66 + workflow runs. 67 + </p> 68 + </div> 69 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 70 + {{ template "addSecretButton" . }} 71 + </div> 72 + </div> 73 + <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"> 74 + {{ range .Secrets }} 75 + {{ template "repo/settings/fragments/secretListing" (list $ .) }} 76 + {{ else }} 77 + <div class="flex items-center justify-center p-2 text-gray-500"> 78 + no secrets added yet 79 + </div> 80 + {{ end }} 81 + </div> 82 + {{ end }} 83 + 84 + {{ define "addSecretButton" }} 85 + <button 86 + class="btn flex items-center gap-2" 87 + popovertarget="add-secret-modal" 88 + popovertargetaction="toggle"> 89 + {{ i "plus" "size-4" }} 90 + add secret 91 + </button> 92 + <div 93 + id="add-secret-modal" 94 + popover 95 + 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"> 96 + {{ template "addSecretModal" . }} 97 + </div> 98 + {{ end}} 99 + 100 + {{ define "addSecretModal" }} 101 + <form 102 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 103 + hx-indicator="#spinner" 104 + hx-swap="none" 105 + class="flex flex-col gap-2" 106 + > 107 + <p class="uppercase p-0">ADD SECRET</p> 108 + <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 109 + <input 110 + type="text" 111 + id="secret-key" 112 + name="key" 113 + required 114 + placeholder="SECRET_NAME" 115 + /> 116 + <textarea 117 + type="text" 118 + id="secret-value" 119 + name="value" 120 + required 121 + placeholder="secret value"></textarea> 122 + <div class="flex gap-2 pt-2"> 123 + <button 124 + type="button" 125 + popovertarget="add-secret-modal" 126 + popovertargetaction="hide" 127 + 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" 128 + > 129 + {{ i "x" "size-4" }} cancel 130 + </button> 131 + <button type="submit" class="btn w-1/2 flex items-center"> 132 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 133 + <span id="spinner" class="group"> 134 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 135 + </span> 136 + </button> 137 + </div> 138 + <div id="add-secret-error" class="text-red-500 dark:text-red-400"></div> 139 + </form> 140 + {{ end }}
+150 -120
appview/pages/templates/repo/settings.html
··· 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 6 7 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 8 - {{ range .Collaborators }} 9 - <div id="collaborator" class="mb-2"> 10 - <a 11 - href="/{{ didOrHandle .Did .Handle }}" 12 - class="no-underline hover:underline text-black dark:text-white" 13 - > 14 - {{ didOrHandle .Did .Handle }} 15 - </a> 16 - <div> 17 - <span class="text-sm text-gray-500 dark:text-gray-400"> 18 - {{ .Role }} 19 - </span> 20 - </div> 21 - </div> 22 - {{ end }} 23 - </div> 24 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 29 > 30 - <label for="collaborator" class="dark:text-white"> 31 - add collaborator 32 - </label> 33 - <input 34 - type="text" 35 - id="collaborator" 36 - name="collaborator" 37 - required 38 - class="dark:bg-gray-700 dark:text-white" 39 - placeholder="enter did or handle" 40 - > 41 - <button 42 - class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" 43 - type="text" 44 - > 45 - <span>add</span> 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - </form> 49 {{ end }} 50 51 <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 54 > 55 - <label for="branch">default branch</label> 56 - <div class="flex gap-2 items-center"> 57 - <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"> 58 - <option 59 - value="" 60 - disabled 61 - selected 62 - > 63 - Choose a default branch 64 - </option> 65 - {{ range .Branches }} 66 - <option 67 - value="{{ .Name }}" 68 - class="py-1" 69 - {{ if .IsDefault }} 70 - selected 71 - {{ end }} 72 - > 73 - {{ .Name }} 74 - </option> 75 - {{ end }} 76 - </select> 77 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 78 - <span>save</span> 79 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 80 - </button> 81 - </div> 82 - </form> 83 - 84 - {{ if .RepoInfo.Roles.IsOwner }} 85 - <form 86 - hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" 87 - class="mt-6 group" 88 - > 89 - <label for="spindle">spindle</label> 90 - <div class="flex gap-2 items-center"> 91 - <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"> 92 - <option 93 - value="" 94 - selected 95 - > 96 - None 97 - </option> 98 - {{ range .Spindles }} 99 - <option 100 - value="{{ . }}" 101 - class="py-1" 102 - {{ if eq . $.CurrentSpindle }} 103 - selected 104 - {{ end }} 105 - > 106 - {{ . }} 107 - </option> 108 - {{ end }} 109 - </select> 110 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 111 - <span>save</span> 112 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 - </button> 114 - </div> 115 </form> 116 - {{ end }} 117 118 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 119 <form 120 hx-confirm="Are you sure you want to delete this repository?" 121 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 122 class="mt-6" 123 - hx-indicator="#delete-repo-spinner" 124 - > 125 - <label for="branch">delete repository</label> 126 - <button class="btn my-2 flex items-center" type="text"> 127 - <span>delete</span> 128 - <span id="delete-repo-spinner" class="group"> 129 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 130 - </span> 131 - </button> 132 - <span> 133 - Deleting a repository is irreversible and permanent. 134 - </span> 135 </form> 136 - {{ end }} 137 138 {{ end }}
··· 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 }}
+2 -2
appview/pages/templates/repo/tags.html
··· 35 <span>{{ .Tag.Tagger.Name }}</span> 36 37 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 - <time>{{ shortTimeFmt .Tag.Tagger.When }}</time> 39 {{ end }} 40 </div> 41 </div> ··· 54 {{ slice .Tag.Target.String 0 8 }} 55 </a> 56 <span>{{ .Tag.Tagger.Name }}</span> 57 - <time>{{ timeFmt .Tag.Tagger.When }}</time> 58 {{ end }} 59 </div> 60 </div>
··· 35 <span>{{ .Tag.Tagger.Name }}</span> 36 37 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 {{ end }} 40 </div> 41 </div> ··· 54 {{ slice .Tag.Target.String 0 8 }} 55 </a> 56 <span>{{ .Tag.Tagger.Name }}</span> 57 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 {{ end }} 59 </div> 60 </div>
+28 -30
appview/pages/templates/repo/tree.html
··· 11 {{ template "repo/fragments/meta" . }} 12 {{ $title := printf "%s at %s &middot; %s" $path .Ref .RepoInfo.FullName }} 13 {{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 - 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 {{ end }} 17 ··· 19 {{define "repoContent"}} 20 <main> 21 <div class="tree"> 22 - {{ $containerstyle := "py-1" }} 23 {{ $linkstyle := "no-underline hover:underline" }} 24 25 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 54 </div> 55 56 {{ range .Files }} 57 - {{ if not .IsFile }} 58 - <div class="{{ $containerstyle }}"> 59 - <div class="flex justify-between items-center"> 60 - <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 61 - <div class="flex items-center gap-2"> 62 - {{ i "folder" "size-4 fill-current" }}{{ .Name }} 63 - </div> 64 - </a> 65 - {{ if .LastCommit}} 66 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 67 - {{ end }} 68 </div> 69 - </div> 70 - {{ end }} 71 - {{ end }} 72 73 - {{ range .Files }} 74 - {{ if .IsFile }} 75 - <div class="{{ $containerstyle }}"> 76 - <div class="flex justify-between items-center"> 77 - <a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 78 - <div class="flex items-center gap-2"> 79 - {{ i "file" "size-4" }}{{ .Name }} 80 - </div> 81 - </a> 82 - {{ if .LastCommit}} 83 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 84 - {{ end }} 85 </div> 86 - </div> 87 {{ end }} 88 - {{ end }} 89 </div> 90 </main> 91 {{end}}
··· 11 {{ template "repo/fragments/meta" . }} 12 {{ $title := printf "%s at %s &middot; %s" $path .Ref .RepoInfo.FullName }} 13 {{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 + 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 {{ end }} 17 ··· 19 {{define "repoContent"}} 20 <main> 21 <div class="tree"> 22 {{ $linkstyle := "no-underline hover:underline" }} 23 24 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 53 </div> 54 55 {{ range .Files }} 56 + <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 + <div class="col-span-6 md:col-span-3"> 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 + {{ $icon := "folder" }} 60 + {{ $iconStyle := "size-4 fill-current" }} 61 + 62 + {{ if .IsFile }} 63 + {{ $icon = "file" }} 64 + {{ $iconStyle = "size-4" }} 65 + {{ end }} 66 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 + <div class="flex items-center gap-2"> 68 + {{ i $icon $iconStyle }}{{ .Name }} 69 + </div> 70 + </a> 71 </div> 72 + 73 + <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 74 + {{ with .LastCommit }} 75 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 76 + {{ end }} 77 + </div> 78 79 + <div class="col-span-6 md:col-span-2 text-right"> 80 + {{ with .LastCommit }} 81 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 82 + {{ end }} 83 </div> 84 + </div> 85 {{ end }} 86 + 87 </div> 88 </main> 89 {{end}}
+2 -2
appview/pages/templates/settings.html
··· 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 {{ .Created | timeFmt }}</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> ··· 112 {{ end }} 113 </div> 114 </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p> 116 </div> 117 <div class="flex gap-2 items-center"> 118 {{ if not .Verified }}
··· 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> ··· 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 }}
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 <div 14 id="add-member-{{ .Instance }}" 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 17 {{ block "addMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }}
··· 13 <div 14 id="add-member-{{ .Instance }}" 15 popover 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 }} 18 </div> 19 {{ end }}
+2 -2
appview/pages/templates/spindles/fragments/spindleListing.html
··· 11 {{ i "hard-drive" "w-4 h-4" }} 12 {{ .Instance }} 13 <span class="text-gray-500"> 14 - {{ .Created | shortTimeFmt }} ago 15 </span> 16 </a> 17 {{ else }} ··· 19 {{ i "hard-drive" "w-4 h-4" }} 20 {{ .Instance }} 21 <span class="text-gray-500"> 22 - {{ .Created | shortTimeFmt }} ago 23 </span> 24 </div> 25 {{ end }}
··· 11 {{ i "hard-drive" "w-4 h-4" }} 12 {{ .Instance }} 13 <span class="text-gray-500"> 14 + {{ template "repo/fragments/shortTimeAgo" .Created }} 15 </span> 16 </a> 17 {{ else }} ··· 19 {{ i "hard-drive" "w-4 h-4" }} 20 {{ .Instance }} 21 <span class="text-gray-500"> 22 + {{ template "repo/fragments/shortTimeAgo" .Created }} 23 </span> 24 </div> 25 {{ end }}
+14 -2
appview/pages/templates/spindles/index.html
··· 7 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 <div class="flex flex-col gap-6"> 10 - {{ block "all" . }} {{ end }} 11 {{ block "register" . }} {{ end }} 12 </div> 13 </section> 14 {{ end }} 15 16 - {{ define "all" }} 17 <section class="rounded w-full flex flex-col gap-2"> 18 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 19 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
··· 7 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 <div class="flex flex-col gap-6"> 10 + {{ block "about" . }} {{ end }} 11 + {{ block "list" . }} {{ end }} 12 {{ block "register" . }} {{ end }} 13 </div> 14 </section> 15 {{ end }} 16 17 + {{ define "about" }} 18 + <section class="rounded flex flex-col gap-2"> 19 + <p class="dark:text-gray-300"> 20 + Spindles are small CI runners. 21 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 22 + Checkout the documentation if you're interested in self-hosting. 23 + </a> 24 + </p> 25 + </section> 26 + {{ end }} 27 + 28 + {{ define "list" }} 29 <section class="rounded w-full flex flex-col gap-2"> 30 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 31 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
+106 -75
appview/pages/templates/timeline.html
··· 49 <p class="text-xl font-bold dark:text-white">Timeline</p> 50 </div> 51 52 - <div class="flex flex-col gap-3 relative"> 53 - <div 54 - class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600" 55 - ></div> 56 - {{ range .Timeline }} 57 - <div 58 - class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit" 59 - > 60 - {{ if .Repo }} 61 - {{ $userHandle := index $.DidHandleMap .Repo.Did }} 62 - <div class="flex items-center"> 63 - <p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2"> 64 - {{ template "user/fragments/picHandle" $userHandle }} 65 - {{ if .Source }} 66 - forked 67 - <a 68 - href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" 69 - class="no-underline hover:underline" 70 - > 71 - {{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a 72 - > 73 - to 74 - <a 75 - href="/{{ $userHandle }}/{{ .Repo.Name }}" 76 - class="no-underline hover:underline" 77 - >{{ .Repo.Name }}</a 78 - > 79 - {{ else }} 80 - created 81 - <a 82 - href="/{{ $userHandle }}/{{ .Repo.Name }}" 83 - class="no-underline hover:underline" 84 - >{{ .Repo.Name }}</a 85 - > 86 - {{ end }} 87 - <time 88 - class="text-gray-700 dark:text-gray-400 text-xs" 89 - >{{ .Repo.Created | timeFmt }}</time 90 - > 91 - </p> 92 - </div> 93 - {{ else if .Follow }} 94 - {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 95 - {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 96 - <div class="flex items-center"> 97 - <p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2"> 98 - {{ template "user/fragments/picHandle" $userHandle }} 99 - followed 100 - {{ template "user/fragments/picHandle" $subjectHandle }} 101 - <time 102 - class="text-gray-700 dark:text-gray-400 text-xs" 103 - >{{ .Follow.FollowedAt | timeFmt }}</time 104 - > 105 - </p> 106 - </div> 107 - {{ else if .Star }} 108 - {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 109 - {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 110 - <div class="flex items-center"> 111 - <p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2"> 112 - {{ template "user/fragments/picHandle" $starrerHandle }} 113 - starred 114 - <a 115 - href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" 116 - class="no-underline hover:underline" 117 - >{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a 118 - > 119 - <time 120 - class="text-gray-700 dark:text-gray-400 text-xs" 121 - >{{ .Star.Created | timeFmt }}</time 122 - > 123 - </p> 124 - </div> 125 - {{ end }} 126 </div> 127 - {{ end }} 128 </div> 129 </div> 130 {{ end }}
··· 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 }}
+104
appview/pages/templates/user/completeSignup.html
···
··· 1 + {{ define "user/completeSignup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta 7 + name="viewport" 8 + content="width=device-width, initial-scale=1.0" 9 + /> 10 + <meta 11 + property="og:title" 12 + content="complete signup ยท tangled" 13 + /> 14 + <meta 15 + property="og:url" 16 + content="https://tangled.sh/complete-signup" 17 + /> 18 + <meta 19 + property="og:description" 20 + content="complete your signup for tangled" 21 + /> 22 + <script src="/static/htmx.min.js"></script> 23 + <link 24 + rel="stylesheet" 25 + href="/static/tw.css?{{ cssContentHash }}" 26 + type="text/css" 27 + /> 28 + <title>complete signup &middot; tangled</title> 29 + </head> 30 + <body class="flex items-center justify-center min-h-screen"> 31 + <main class="max-w-md px-6 -mt-4"> 32 + <h1 33 + class="text-center text-2xl font-semibold italic dark:text-white" 34 + > 35 + tangled 36 + </h1> 37 + <h2 class="text-center text-xl italic dark:text-white"> 38 + tightly-knit social coding. 39 + </h2> 40 + <form 41 + class="mt-4 max-w-sm mx-auto" 42 + hx-post="/signup/complete" 43 + hx-swap="none" 44 + hx-disabled-elt="#complete-signup-button" 45 + > 46 + <div class="flex flex-col"> 47 + <label for="code">verification code</label> 48 + <input 49 + type="text" 50 + id="code" 51 + name="code" 52 + tabindex="1" 53 + required 54 + placeholder="tngl-sh-foo-bar" 55 + /> 56 + <span class="text-sm text-gray-500 mt-1"> 57 + Enter the code sent to your email. 58 + </span> 59 + </div> 60 + 61 + <div class="flex flex-col mt-4"> 62 + <label for="username">desired username</label> 63 + <input 64 + type="text" 65 + id="username" 66 + name="username" 67 + tabindex="2" 68 + required 69 + placeholder="jason" 70 + /> 71 + <span class="text-sm text-gray-500 mt-1"> 72 + Your complete handle will be of the form <code>user.tngl.sh</code>. 73 + </span> 74 + </div> 75 + 76 + <div class="flex flex-col mt-4"> 77 + <label for="password">password</label> 78 + <input 79 + type="password" 80 + id="password" 81 + name="password" 82 + tabindex="3" 83 + required 84 + /> 85 + <span class="text-sm text-gray-500 mt-1"> 86 + Choose a strong password for your account. 87 + </span> 88 + </div> 89 + 90 + <button 91 + class="btn-create w-full my-2 mt-6" 92 + type="submit" 93 + id="complete-signup-button" 94 + tabindex="4" 95 + > 96 + <span>complete signup</span> 97 + </button> 98 + </form> 99 + <p id="signup-error" class="error w-full"></p> 100 + <p id="signup-msg" class="dark:text-white w-full"></p> 101 + </main> 102 + </body> 103 + </html> 104 + {{ end }}
+6 -8
appview/pages/templates/user/fragments/picHandle.html
··· 1 {{ define "user/fragments/picHandle" }} 2 - <a href="/{{ . }}" class="flex items-center"> 3 - <img 4 - src="{{ tinyAvatar . }}" 5 - alt="{{ . }}" 6 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 7 - /> 8 - {{ . | truncateAt30 }} 9 - </a> 10 {{ end }}
··· 1 {{ define "user/fragments/picHandle" }} 2 + <img 3 + src="{{ tinyAvatar . }}" 4 + alt="{{ . }}" 5 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 + /> 7 + {{ . | truncateAt30 }} 8 {{ end }}
+5
appview/pages/templates/user/fragments/picHandleLink.html
···
··· 1 + {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ . }}" class="flex items-center"> 3 + {{ template "user/fragments/picHandle" . }} 4 + </a> 5 + {{ end }}
+57
appview/pages/templates/user/fragments/repoCard.html
···
··· 1 + {{ define "user/fragments/repoCard" }} 2 + {{ $root := index . 0 }} 3 + {{ $repo := index . 1 }} 4 + {{ $fullName := index . 2 }} 5 + 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 -}} 14 + </div> 15 + {{ with .Description }} 16 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 17 + {{ . }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ if .RepoStats }} 22 + {{ block "repoStats" .RepoStats }} {{ end }} 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + {{ end }} 27 + 28 + {{ define "repoStats" }} 29 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 30 + {{ 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> 35 + {{ end }} 36 + {{ 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> 41 + {{ end }} 42 + {{ 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> 47 + {{ end }} 48 + {{ 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> 53 + {{ end }} 54 + </div> 55 + {{ end }} 56 + 57 +
+54 -7
appview/pages/templates/user/login.html
··· 17 /> 18 <meta 19 property="og:description" 20 - content="login to tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link ··· 25 href="/static/tw.css?{{ cssContentHash }}" 26 type="text/css" 27 /> 28 - <title>login &middot; tangled</title> 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 31 <main class="max-w-md px-6 -mt-4"> ··· 51 name="handle" 52 tabindex="1" 53 required 54 /> 55 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 60 </span> 61 </div> 62 ··· 69 <span>login</span> 70 </button> 71 </form> 72 - <p class="text-sm text-gray-500"> 73 Join our <a href="https://chat.tangled.sh">Discord</a> or 74 IRC channel: 75 <a href="https://web.libera.chat/#tangled"
··· 17 /> 18 <meta 19 property="og:description" 20 + content="login to or sign up for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link ··· 25 href="/static/tw.css?{{ cssContentHash }}" 26 type="text/css" 27 /> 28 + <title>login or sign up &middot; tangled</title> 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 31 <main class="max-w-md px-6 -mt-4"> ··· 51 name="handle" 52 tabindex="1" 53 required 54 + placeholder="foo.tngl.sh" 55 /> 56 <span class="text-sm text-gray-500 mt-1"> 57 + Use your <a href="https://atproto.com">ATProto</a> 58 + handle to log in. If you're unsure, this is likely 59 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 </span> 61 </div> 62 ··· 69 <span>login</span> 70 </button> 71 </form> 72 + <hr class="my-4"> 73 + <p class="text-sm text-gray-500 mt-4"> 74 + Alternatively, you may create an account on Tangled below. You will 75 + get a <code>user.tngl.sh</code> handle. 76 + </p> 77 + 78 + <details class="group"> 79 + 80 + <summary 81 + class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2" 82 + > 83 + create an account 84 + 85 + <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div> 86 + <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div> 87 + </summary> 88 + <form 89 + class="mt-4 max-w-sm mx-auto" 90 + hx-post="/signup" 91 + hx-swap="none" 92 + hx-disabled-elt="#signup-button" 93 + > 94 + <div class="flex flex-col mt-2"> 95 + <label for="email">email</label> 96 + <input 97 + type="email" 98 + id="email" 99 + name="email" 100 + tabindex="4" 101 + required 102 + placeholder="jason@bourne.co" 103 + /> 104 + </div> 105 + <span class="text-sm text-gray-500 mt-1"> 106 + You will receive an email with a code. Enter that, along with your 107 + desired username and password in the next page to complete your registration. 108 + </span> 109 + <button 110 + class="btn w-full my-2 mt-6" 111 + type="submit" 112 + id="signup-button" 113 + tabindex="7" 114 + > 115 + <span>sign up</span> 116 + </button> 117 + </form> 118 + </details> 119 + <p class="text-sm text-gray-500 mt-6"> 120 Join our <a href="https://chat.tangled.sh">Discord</a> or 121 IRC channel: 122 <a href="https://web.libera.chat/#tangled"
+7 -49
appview/pages/templates/user/profile.html
··· 8 {{ end }} 9 10 {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 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-3 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-3 order-3 md:order-3"> 25 {{ block "profileTimeline" . }}{{ end }} 26 </div> 27 </div> ··· 258 </button> 259 {{ end }} 260 </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4"> 262 {{ range .Repos }} 263 - <div 264 - id="repo-card" 265 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 266 - <div id="repo-card-name" class="font-medium"> 267 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 268 - >{{ .Name }}</a 269 - > 270 - </div> 271 - {{ if .Description }} 272 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 273 - {{ .Description }} 274 - </div> 275 - {{ end }} 276 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 277 - {{ if .RepoStats.StarCount }} 278 - <div class="flex gap-1 items-center text-sm"> 279 - {{ i "star" "w-3 h-3 fill-current" }} 280 - <span>{{ .RepoStats.StarCount }}</span> 281 - </div> 282 - {{ end }} 283 - </div> 284 - </div> 285 {{ else }} 286 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 287 {{ end }} ··· 295 <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 296 <div id="collaborating" class="grid grid-cols-1 gap-4"> 297 {{ range .CollaboratingRepos }} 298 - <div 299 - id="repo-card" 300 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 301 - <div id="repo-card-name" class="font-medium dark:text-white"> 302 - <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 303 - {{ index $.DidHandleMap .Did }}/{{ .Name }} 304 - </a> 305 - </div> 306 - {{ if .Description }} 307 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 308 - {{ .Description }} 309 - </div> 310 - {{ end }} 311 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 312 - {{ if .RepoStats.StarCount }} 313 - <div class="flex gap-1 items-center text-sm"> 314 - {{ i "star" "w-3 h-3 fill-current" }} 315 - <span>{{ .RepoStats.StarCount }}</span> 316 - </div> 317 - {{ end }} 318 - </div> 319 - </div> 320 {{ else }} 321 <p class="px-6 dark:text-white">This user is not collaborating.</p> 322 {{ end }}
··· 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> ··· 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 }} ··· 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 }}
+4 -25
appview/pages/templates/user/repos.html
··· 8 {{ end }} 9 10 {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 order-1 md:order-1"> 13 {{ template "user/fragments/profileCard" .Card }} 14 </div> 15 - <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 16 {{ block "ownRepos" . }}{{ end }} 17 </div> 18 </div> ··· 22 <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 {{ range .Repos }} 25 - <div 26 - id="repo-card" 27 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 28 - <div id="repo-card-name" class="font-medium"> 29 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 30 - >{{ .Name }}</a 31 - > 32 - </div> 33 - {{ if .Description }} 34 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 35 - {{ .Description }} 36 - </div> 37 - {{ end }} 38 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 39 - {{ if .RepoStats.StarCount }} 40 - <div class="flex gap-1 items-center text-sm"> 41 - {{ i "star" "w-3 h-3 fill-current" }} 42 - <span>{{ .RepoStats.StarCount }}</span> 43 - </div> 44 - {{ end }} 45 - </div> 46 - </div> 47 {{ else }} 48 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 49 {{ end }}
··· 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> ··· 22 <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 {{ range .Repos }} 25 + {{ template "user/fragments/repoCard" (list $ . false) }} 26 {{ else }} 27 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 {{ end }}
+1 -5
appview/pipelines/pipelines.go
··· 11 12 "tangled.sh/tangled.sh/core/appview/config" 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/idresolver" 15 "tangled.sh/tangled.sh/core/appview/oauth" 16 "tangled.sh/tangled.sh/core/appview/pages" 17 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 "tangled.sh/tangled.sh/core/eventconsumer" 19 "tangled.sh/tangled.sh/core/log" 20 "tangled.sh/tangled.sh/core/rbac" 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket" 25 - "github.com/posthog/posthog-go" 26 ) 27 28 type Pipelines struct { ··· 34 spindlestream *eventconsumer.Consumer 35 db *db.DB 36 enforcer *rbac.Enforcer 37 - posthog posthog.Client 38 logger *slog.Logger 39 } 40 ··· 46 idResolver *idresolver.Resolver, 47 db *db.DB, 48 config *config.Config, 49 - posthog posthog.Client, 50 enforcer *rbac.Enforcer, 51 ) *Pipelines { 52 logger := log.New("pipelines") ··· 58 config: config, 59 spindlestream: spindlestream, 60 db: db, 61 - posthog: posthog, 62 enforcer: enforcer, 63 logger: logger, 64 }
··· 11 12 "tangled.sh/tangled.sh/core/appview/config" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/oauth" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 "tangled.sh/tangled.sh/core/appview/reporesolver" 17 "tangled.sh/tangled.sh/core/eventconsumer" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/log" 20 "tangled.sh/tangled.sh/core/rbac" 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket" 25 ) 26 27 type Pipelines struct { ··· 33 spindlestream *eventconsumer.Consumer 34 db *db.DB 35 enforcer *rbac.Enforcer 36 logger *slog.Logger 37 } 38 ··· 44 idResolver *idresolver.Resolver, 45 db *db.DB, 46 config *config.Config, 47 enforcer *rbac.Enforcer, 48 ) *Pipelines { 49 logger := log.New("pipelines") ··· 55 config: config, 56 spindlestream: spindlestream, 57 db: db, 58 enforcer: enforcer, 59 logger: logger, 60 }
+131
appview/posthog/notifier.go
···
··· 1 + package posthog_service 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.sh/tangled.sh/core/appview/db" 9 + "tangled.sh/tangled.sh/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.OwnerDid, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: follow.UserDid, 104 + Event: "follow", 105 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 + }) 107 + if err != nil { 108 + log.Println("failed to enqueue posthog event:", err) 109 + } 110 + } 111 + 112 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 + err := n.client.Enqueue(posthog.Capture{ 114 + DistinctId: follow.UserDid, 115 + Event: "unfollow", 116 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 + }) 118 + if err != nil { 119 + log.Println("failed to enqueue posthog event:", err) 120 + } 121 + } 122 + 123 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 + err := n.client.Enqueue(posthog.Capture{ 125 + DistinctId: profile.Did, 126 + Event: "edit_profile", 127 + }) 128 + if err != nil { 129 + log.Println("failed to enqueue posthog event:", err) 130 + } 131 + }
+71 -42
appview/pulls/pulls.go
··· 14 "time" 15 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/types" 27 28 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 "github.com/go-chi/chi/v5" 33 "github.com/google/uuid" 34 - "github.com/posthog/posthog-go" 35 ) 36 37 type Pulls struct { ··· 41 idResolver *idresolver.Resolver 42 db *db.DB 43 config *config.Config 44 - posthog posthog.Client 45 } 46 47 func New( ··· 51 resolver *idresolver.Resolver, 52 db *db.DB, 53 config *config.Config, 54 - posthog posthog.Client, 55 ) *Pulls { 56 return &Pulls{ 57 oauth: oauth, ··· 60 idResolver: resolver, 61 db: db, 62 config: config, 63 - posthog: posthog, 64 } 65 } 66 ··· 198 m[p.Sha] = p 199 } 200 201 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 202 LoggedInUser: user, 203 RepoInfo: repoInfo, ··· 208 MergeCheck: mergeCheckResponse, 209 ResubmitCheck: resubmitResult, 210 Pipelines: m, 211 }) 212 } 213 ··· 340 return 341 } 342 343 pull, ok := r.Context().Value("pull").(*db.Pull) 344 if !ok { 345 log.Println("failed to get pull") ··· 380 Round: roundIdInt, 381 Submission: pull.Submissions[roundIdInt], 382 Diff: &diff, 383 }) 384 385 } ··· 393 return 394 } 395 396 pull, ok := r.Context().Value("pull").(*db.Pull) 397 if !ok { 398 log.Println("failed to get pull") ··· 448 Round: roundIdInt, 449 DidHandleMap: didHandleMap, 450 Interdiff: interdiff, 451 }) 452 - return 453 } 454 455 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 529 530 // we want to group all stacked PRs into just one list 531 stacks := make(map[string]db.Stack) 532 n := 0 533 for _, p := range pulls { 534 // this PR is stacked 535 if p.StackId != "" { 536 // we have already seen this PR stack ··· 549 } 550 pulls = pulls[:n] 551 552 identsToResolve := make([]string, len(pulls)) 553 for i, pull := range pulls { 554 identsToResolve[i] = pull.OwnerDid ··· 570 DidHandleMap: didHandleMap, 571 FilteringBy: state, 572 Stacks: stacks, 573 }) 574 - return 575 } 576 577 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 642 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 Collection: tangled.RepoPullCommentNSID, 644 Repo: user.Did, 645 - Rkey: appview.TID(), 646 Record: &lexutil.LexiconTypeDecoder{ 647 Val: &tangled.RepoPullComment{ 648 Repo: &atUri, ··· 659 return 660 } 661 662 - // Create the pull comment in the database with the commentAt field 663 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 664 OwnerDid: user.Did, 665 RepoAt: f.RepoAt.String(), 666 PullId: pull.PullId, 667 Body: body, 668 CommentAt: atResp.Uri, 669 SubmissionId: pull.Submissions[roundNumber].ID, 670 - }) 671 if err != nil { 672 log.Println("failed to create pull comment", err) 673 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 681 return 682 } 683 684 - if !s.config.Core.Dev { 685 - err = s.posthog.Enqueue(posthog.Capture{ 686 - DistinctId: user.Did, 687 - Event: "new_pull_comment", 688 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, 689 - }) 690 - if err != nil { 691 - log.Println("failed to enqueue posthog event:", err) 692 - } 693 - } 694 695 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 696 return ··· 1019 body = formatPatches[0].Body 1020 } 1021 1022 - rkey := appview.TID() 1023 initialSubmission := db.PullSubmission{ 1024 Patch: patch, 1025 SourceRev: sourceRev, 1026 } 1027 - err = db.NewPull(tx, &db.Pull{ 1028 Title: title, 1029 Body: body, 1030 TargetBranch: targetBranch, ··· 1035 &initialSubmission, 1036 }, 1037 PullSource: pullSource, 1038 - }) 1039 if err != nil { 1040 log.Println("failed to create pull request", err) 1041 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1075 return 1076 } 1077 1078 - if !s.config.Core.Dev { 1079 - err = s.posthog.Enqueue(posthog.Capture{ 1080 - DistinctId: user.Did, 1081 - Event: "new_pull", 1082 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, 1083 - }) 1084 - if err != nil { 1085 - log.Println("failed to enqueue posthog event:", err) 1086 - } 1087 - } 1088 1089 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1090 } ··· 1647 } 1648 1649 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1650 - return 1651 } 1652 1653 func (s *Pulls) resubmitStackedPullHelper( ··· 1891 } 1892 1893 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1894 - return 1895 } 1896 1897 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2015 2016 // auth filter: only owner or collaborators can close 2017 roles := f.RolesInRepo(user) 2018 isCollaborator := roles.IsCollaborator() 2019 isPullAuthor := user.Did == pull.OwnerDid 2020 - isCloseAllowed := isCollaborator || isPullAuthor 2021 if !isCloseAllowed { 2022 log.Println("failed to close pull") 2023 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2061 } 2062 2063 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2064 - return 2065 } 2066 2067 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2083 2084 // auth filter: only owner or collaborators can close 2085 roles := f.RolesInRepo(user) 2086 isCollaborator := roles.IsCollaborator() 2087 isPullAuthor := user.Did == pull.OwnerDid 2088 - isCloseAllowed := isCollaborator || isPullAuthor 2089 if !isCloseAllowed { 2090 log.Println("failed to close pull") 2091 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2129 } 2130 2131 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2132 - return 2133 } 2134 2135 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2155 2156 title := fp.Title 2157 body := fp.Body 2158 - rkey := appview.TID() 2159 2160 initialSubmission := db.PullSubmission{ 2161 Patch: fp.Raw,
··· 14 "time" 15 16 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 + "tangled.sh/tangled.sh/core/appview/notify" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 "github.com/go-chi/chi/v5" 34 "github.com/google/uuid" 35 ) 36 37 type Pulls struct { ··· 41 idResolver *idresolver.Resolver 42 db *db.DB 43 config *config.Config 44 + notifier notify.Notifier 45 } 46 47 func New( ··· 51 resolver *idresolver.Resolver, 52 db *db.DB, 53 config *config.Config, 54 + notifier notify.Notifier, 55 ) *Pulls { 56 return &Pulls{ 57 oauth: oauth, ··· 60 idResolver: resolver, 61 db: db, 62 config: config, 63 + notifier: notifier, 64 } 65 } 66 ··· 198 m[p.Sha] = p 199 } 200 201 + reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 202 + if err != nil { 203 + log.Println("failed to get pull reactions") 204 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 205 + } 206 + 207 + userReactions := map[db.ReactionKind]bool{} 208 + if user != nil { 209 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 210 + } 211 + 212 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 213 LoggedInUser: user, 214 RepoInfo: repoInfo, ··· 219 MergeCheck: mergeCheckResponse, 220 ResubmitCheck: resubmitResult, 221 Pipelines: m, 222 + 223 + OrderedReactionKinds: db.OrderedReactionKinds, 224 + Reactions: reactionCountMap, 225 + UserReacted: userReactions, 226 }) 227 } 228 ··· 355 return 356 } 357 358 + var diffOpts types.DiffOpts 359 + if d := r.URL.Query().Get("diff"); d == "split" { 360 + diffOpts.Split = true 361 + } 362 + 363 pull, ok := r.Context().Value("pull").(*db.Pull) 364 if !ok { 365 log.Println("failed to get pull") ··· 400 Round: roundIdInt, 401 Submission: pull.Submissions[roundIdInt], 402 Diff: &diff, 403 + DiffOpts: diffOpts, 404 }) 405 406 } ··· 414 return 415 } 416 417 + var diffOpts types.DiffOpts 418 + if d := r.URL.Query().Get("diff"); d == "split" { 419 + diffOpts.Split = true 420 + } 421 + 422 pull, ok := r.Context().Value("pull").(*db.Pull) 423 if !ok { 424 log.Println("failed to get pull") ··· 474 Round: roundIdInt, 475 DidHandleMap: didHandleMap, 476 Interdiff: interdiff, 477 + DiffOpts: diffOpts, 478 }) 479 } 480 481 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 555 556 // we want to group all stacked PRs into just one list 557 stacks := make(map[string]db.Stack) 558 + var shas []string 559 n := 0 560 for _, p := range pulls { 561 + // store the sha for later 562 + shas = append(shas, p.LatestSha()) 563 // this PR is stacked 564 if p.StackId != "" { 565 // we have already seen this PR stack ··· 578 } 579 pulls = pulls[:n] 580 581 + repoInfo := f.RepoInfo(user) 582 + ps, err := db.GetPipelineStatuses( 583 + s.db, 584 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 585 + db.FilterEq("repo_name", repoInfo.Name), 586 + db.FilterEq("knot", repoInfo.Knot), 587 + db.FilterIn("sha", shas), 588 + ) 589 + if err != nil { 590 + log.Printf("failed to fetch pipeline statuses: %s", err) 591 + // non-fatal 592 + } 593 + m := make(map[string]db.Pipeline) 594 + for _, p := range ps { 595 + m[p.Sha] = p 596 + } 597 + 598 identsToResolve := make([]string, len(pulls)) 599 for i, pull := range pulls { 600 identsToResolve[i] = pull.OwnerDid ··· 616 DidHandleMap: didHandleMap, 617 FilteringBy: state, 618 Stacks: stacks, 619 + Pipelines: m, 620 }) 621 } 622 623 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 689 Collection: tangled.RepoPullCommentNSID, 690 Repo: user.Did, 691 + Rkey: tid.TID(), 692 Record: &lexutil.LexiconTypeDecoder{ 693 Val: &tangled.RepoPullComment{ 694 Repo: &atUri, ··· 705 return 706 } 707 708 + comment := &db.PullComment{ 709 OwnerDid: user.Did, 710 RepoAt: f.RepoAt.String(), 711 PullId: pull.PullId, 712 Body: body, 713 CommentAt: atResp.Uri, 714 SubmissionId: pull.Submissions[roundNumber].ID, 715 + } 716 + 717 + // Create the pull comment in the database with the commentAt field 718 + commentId, err := db.NewPullComment(tx, comment) 719 if err != nil { 720 log.Println("failed to create pull comment", err) 721 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 729 return 730 } 731 732 + s.notifier.NewPullComment(r.Context(), comment) 733 734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 735 return ··· 1058 body = formatPatches[0].Body 1059 } 1060 1061 + rkey := tid.TID() 1062 initialSubmission := db.PullSubmission{ 1063 Patch: patch, 1064 SourceRev: sourceRev, 1065 } 1066 + pull := &db.Pull{ 1067 Title: title, 1068 Body: body, 1069 TargetBranch: targetBranch, ··· 1074 &initialSubmission, 1075 }, 1076 PullSource: pullSource, 1077 + } 1078 + err = db.NewPull(tx, pull) 1079 if err != nil { 1080 log.Println("failed to create pull request", err) 1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1115 return 1116 } 1117 1118 + s.notifier.NewPull(r.Context(), pull) 1119 1120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1121 } ··· 1678 } 1679 1680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1681 } 1682 1683 func (s *Pulls) resubmitStackedPullHelper( ··· 1921 } 1922 1923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1924 } 1925 1926 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2044 2045 // auth filter: only owner or collaborators can close 2046 roles := f.RolesInRepo(user) 2047 + isOwner := roles.IsOwner() 2048 isCollaborator := roles.IsCollaborator() 2049 isPullAuthor := user.Did == pull.OwnerDid 2050 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2051 if !isCloseAllowed { 2052 log.Println("failed to close pull") 2053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2091 } 2092 2093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2094 } 2095 2096 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2112 2113 // auth filter: only owner or collaborators can close 2114 roles := f.RolesInRepo(user) 2115 + isOwner := roles.IsOwner() 2116 isCollaborator := roles.IsCollaborator() 2117 isPullAuthor := user.Did == pull.OwnerDid 2118 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2119 if !isCloseAllowed { 2120 log.Println("failed to close pull") 2121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2159 } 2160 2161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2162 } 2163 2164 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2184 2185 title := fp.Title 2186 body := fp.Body 2187 + rkey := tid.TID() 2188 2189 initialSubmission := db.PullSubmission{ 2190 Patch: fp.Raw,
+2
appview/pulls/router.go
··· 44 r.Get("/", s.ResubmitPull) 45 r.Post("/", s.ResubmitPull) 46 }) 47 r.Post("/close", s.ClosePull) 48 r.Post("/reopen", s.ReopenPull) 49 // collaborators only
··· 44 r.Get("/", s.ResubmitPull) 45 r.Post("/", s.ResubmitPull) 46 }) 47 + // permissions here require us to know pull author 48 + // it is handled within the route 49 r.Post("/close", s.ClosePull) 50 r.Post("/reopen", s.ReopenPull) 51 // collaborators only
+2 -2
appview/repo/artifact.go
··· 14 "github.com/go-git/go-git/v5/plumbing" 15 "github.com/ipfs/go-cid" 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 "tangled.sh/tangled.sh/core/appview/db" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 "tangled.sh/tangled.sh/core/knotclient" 22 "tangled.sh/tangled.sh/core/types" 23 ) 24 ··· 64 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 67 - rkey := appview.TID() 68 createdAt := time.Now() 69 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
··· 14 "github.com/go-git/go-git/v5/plumbing" 15 "github.com/ipfs/go-cid" 16 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 "tangled.sh/tangled.sh/core/knotclient" 21 + "tangled.sh/tangled.sh/core/tid" 22 "tangled.sh/tangled.sh/core/types" 23 ) 24 ··· 64 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 67 + rkey := tid.TID() 68 createdAt := time.Now() 69 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+49 -25
appview/repo/index.go
··· 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 } 60 61 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 62 if a.Name == result.Ref { 63 return -1 ··· 123 } 124 } 125 126 - languageInfo, err := getLanguageInfo(f, signedClient, ref) 127 if err != nil { 128 log.Printf("failed to compute language percentages: %s", err) 129 // non-fatal ··· 153 Languages: languageInfo, 154 Pipelines: pipelines, 155 }) 156 - return 157 } 158 159 - func getLanguageInfo( 160 f *reporesolver.ResolvedRepo, 161 signedClient *knotclient.SignedClient, 162 - ref string, 163 ) ([]types.RepoLanguageDetails, error) { 164 - repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 165 - if err != nil { 166 - return []types.RepoLanguageDetails{}, err 167 - } 168 - if repoLanguages == nil { 169 - repoLanguages = &types.RepoLanguageResponse{Languages: make(map[string]int64)} 170 - } 171 - 172 - var totalSize int64 173 - for _, fileSize := range repoLanguages.Languages { 174 - totalSize += fileSize 175 - } 176 177 - var languageStats []types.RepoLanguageDetails 178 - var otherPercentage float32 = 0 179 180 - for lang, size := range repoLanguages.Languages { 181 - percentage := (float32(size) / float32(totalSize)) * 100 182 183 - if percentage <= 0.5 { 184 - otherPercentage += percentage 185 - continue 186 } 187 188 - color := enry.GetColor(lang) 189 190 - languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color}) 191 } 192 193 sort.Slice(languageStats, func(i, j int) bool {
··· 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 } 60 61 + sortFiles(result.Files) 62 + 63 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 64 if a.Name == result.Ref { 65 return -1 ··· 125 } 126 } 127 128 + // TODO: a bit dirty 129 + languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 130 if err != nil { 131 log.Printf("failed to compute language percentages: %s", err) 132 // non-fatal ··· 156 Languages: languageInfo, 157 Pipelines: pipelines, 158 }) 159 } 160 161 + func (rp *Repo) getLanguageInfo( 162 f *reporesolver.ResolvedRepo, 163 signedClient *knotclient.SignedClient, 164 + isDefaultRef bool, 165 ) ([]types.RepoLanguageDetails, error) { 166 + // first attempt to fetch from db 167 + langs, err := db.GetRepoLanguages( 168 + rp.db, 169 + db.FilterEq("repo_at", f.RepoAt), 170 + db.FilterEq("ref", f.Ref), 171 + ) 172 173 + if err != nil || langs == nil { 174 + // non-fatal, fetch langs from ks 175 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 176 + if err != nil { 177 + return nil, err 178 + } 179 + if ls == nil { 180 + return nil, nil 181 + } 182 183 + for l, s := range ls.Languages { 184 + langs = append(langs, db.RepoLanguage{ 185 + RepoAt: f.RepoAt, 186 + Ref: f.Ref, 187 + IsDefaultRef: isDefaultRef, 188 + Language: l, 189 + Bytes: s, 190 + }) 191 + } 192 193 + // update appview's cache 194 + err = db.InsertRepoLanguages(rp.db, langs) 195 + if err != nil { 196 + // non-fatal 197 + log.Println("failed to cache lang results", err) 198 } 199 + } 200 201 + var total int64 202 + for _, l := range langs { 203 + total += l.Bytes 204 + } 205 206 + var languageStats []types.RepoLanguageDetails 207 + for _, l := range langs { 208 + percentage := float32(l.Bytes) / float32(total) * 100 209 + color := enry.GetColor(l.Language) 210 + languageStats = append(languageStats, types.RepoLanguageDetails{ 211 + Name: l.Language, 212 + Percentage: percentage, 213 + Color: color, 214 + }) 215 } 216 217 sort.Slice(languageStats, func(i, j int) bool {
+441 -128
appview/repo/repo.go
··· 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "net/url" 13 - "path" 14 "slices" 15 - "sort" 16 "strconv" 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 "tangled.sh/tangled.sh/core/appview/commitverify" 23 "tangled.sh/tangled.sh/core/appview/config" 24 "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/idresolver" 26 "tangled.sh/tangled.sh/core/appview/oauth" 27 "tangled.sh/tangled.sh/core/appview/pages" 28 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 "tangled.sh/tangled.sh/core/eventconsumer" 31 "tangled.sh/tangled.sh/core/knotclient" 32 "tangled.sh/tangled.sh/core/patchutil" 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/types" 35 36 securejoin "github.com/cyphar/filepath-securejoin" 37 "github.com/go-chi/chi/v5" 38 "github.com/go-git/go-git/v5/plumbing" 39 - "github.com/posthog/posthog-go" 40 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 lexutil "github.com/bluesky-social/indigo/lex/util" 43 ) 44 ··· 51 spindlestream *eventconsumer.Consumer 52 db *db.DB 53 enforcer *rbac.Enforcer 54 - posthog posthog.Client 55 } 56 57 func New( ··· 62 idResolver *idresolver.Resolver, 63 db *db.DB, 64 config *config.Config, 65 - posthog posthog.Client, 66 enforcer *rbac.Enforcer, 67 ) *Repo { 68 return &Repo{oauth: oauth, 69 repoResolver: repoResolver, ··· 72 config: config, 73 spindlestream: spindlestream, 74 db: db, 75 - posthog: posthog, 76 enforcer: enforcer, 77 } 78 } 79 ··· 106 return 107 } 108 109 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 110 if err != nil { 111 log.Println("failed to reach knotserver", err) 112 return 113 } 114 115 tagMap := make(map[string][]string) 116 - for _, tag := range result.Tags { 117 hash := tag.Hash 118 if tag.Tag != nil { 119 hash = tag.Tag.Target.String() ··· 121 tagMap[hash] = append(tagMap[hash], tag.Name) 122 } 123 124 user := rp.oauth.GetUser(r) 125 126 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) ··· 154 VerifiedCommits: vc, 155 Pipelines: pipelines, 156 }) 157 - return 158 } 159 160 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { ··· 169 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 170 RepoInfo: f.RepoInfo(user), 171 }) 172 - return 173 } 174 175 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 268 protocol = "https" 269 } 270 271 if !plumbing.IsHash(ref) { 272 rp.pages.Error404(w) 273 return ··· 321 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 322 VerifiedCommit: vc, 323 Pipeline: pipeline, 324 }) 325 - return 326 } 327 328 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 359 360 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 361 // so we can safely redirect to the "parent" (which is the same file). 362 - if len(result.Files) == 0 && result.Parent == treePath { 363 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 364 return 365 } ··· 374 } 375 } 376 377 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 378 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 379 380 rp.pages.RepoTree(w, pages.RepoTreeParams{ 381 LoggedInUser: user, 382 BreadCrumbs: breadcrumbs, 383 - BaseTreeLink: baseTreeLink, 384 - BaseBlobLink: baseBlobLink, 385 RepoInfo: f.RepoInfo(user), 386 RepoTreeResponse: result, 387 }) 388 - return 389 } 390 391 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 443 ArtifactMap: artifactMap, 444 DanglingArtifacts: danglingArtifacts, 445 }) 446 - return 447 } 448 449 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 465 return 466 } 467 468 - slices.SortFunc(result.Branches, func(a, b types.Branch) int { 469 - if a.IsDefault { 470 - return -1 471 - } 472 - if b.IsDefault { 473 - return 1 474 - } 475 - if a.Commit != nil && b.Commit != nil { 476 - if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 477 - return 1 478 - } else { 479 - return -1 480 - } 481 - } 482 - return strings.Compare(a.Name, b.Name) * -1 483 - }) 484 485 user := rp.oauth.GetUser(r) 486 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 488 RepoInfo: f.RepoInfo(user), 489 RepoBranchesResponse: *result, 490 }) 491 - return 492 } 493 494 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 539 showRendered = r.URL.Query().Get("code") != "true" 540 } 541 542 user := rp.oauth.GetUser(r) 543 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 544 LoggedInUser: user, ··· 547 BreadCrumbs: breadcrumbs, 548 ShowRendered: showRendered, 549 RenderToggle: renderToggle, 550 }) 551 - return 552 } 553 554 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 555 f, err := rp.repoResolver.Resolve(r) 556 if err != nil { 557 log.Println("failed to get repo and knot", err) 558 return 559 } 560 ··· 565 if !rp.config.Core.Dev { 566 protocol = "https" 567 } 568 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 569 if err != nil { 570 - log.Println("failed to reach knotserver", err) 571 return 572 } 573 574 - body, err := io.ReadAll(resp.Body) 575 - if err != nil { 576 - log.Printf("Error reading response body: %v", err) 577 return 578 } 579 580 - var result types.RepoBlobResponse 581 - err = json.Unmarshal(body, &result) 582 if err != nil { 583 - log.Println("failed to parse response:", err) 584 return 585 } 586 587 - if result.IsBinary { 588 - w.Header().Set("Content-Type", "application/octet-stream") 589 w.Write(body) 590 return 591 } 592 - 593 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 594 - w.Write([]byte(result.Contents)) 595 - return 596 } 597 598 // modify the spindle configured for this repo 599 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 600 f, err := rp.repoResolver.Resolve(r) 601 if err != nil { 602 - log.Println("failed to get repo and knot", err) 603 - w.WriteHeader(http.StatusBadRequest) 604 return 605 } 606 607 repoAt := f.RepoAt 608 rkey := repoAt.RecordKey().String() 609 if rkey == "" { 610 - log.Println("invalid aturi for repo", err) 611 - w.WriteHeader(http.StatusInternalServerError) 612 return 613 } 614 615 - user := rp.oauth.GetUser(r) 616 - 617 newSpindle := r.FormValue("spindle") 618 client, err := rp.oauth.AuthorizedClient(r) 619 if err != nil { 620 - log.Println("failed to get client") 621 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 622 return 623 } 624 625 // ensure that this is a valid spindle for this user 626 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 627 if err != nil { 628 - log.Println("failed to get valid spindles") 629 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 630 return 631 } 632 633 if !slices.Contains(validSpindles, newSpindle) { 634 - log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles) 635 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 636 return 637 } 638 639 // optimistic update 640 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 641 if err != nil { 642 - log.Println("failed to perform update-spindle query", err) 643 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 644 return 645 } 646 647 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 648 if err != nil { 649 - // failed to get record 650 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 651 return 652 } 653 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 668 }) 669 670 if err != nil { 671 - log.Println("failed to perform update-spindle query", err) 672 - // failed to get record 673 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.") 674 return 675 } 676 ··· 680 eventconsumer.NewSpindleSource(newSpindle), 681 ) 682 683 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 684 } 685 686 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 687 f, err := rp.repoResolver.Resolve(r) 688 if err != nil { 689 - log.Println("failed to get repo and knot", err) 690 return 691 } 692 693 collaborator := r.FormValue("collaborator") 694 if collaborator == "" { 695 - http.Error(w, "malformed form", http.StatusBadRequest) 696 return 697 } 698 699 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 700 if err != nil { 701 - w.Write([]byte("failed to resolve collaborator did to a handle")) 702 return 703 } 704 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 705 706 - // TODO: create an atproto record for this 707 708 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 709 if err != nil { 710 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 711 return 712 } 713 714 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 715 if err != nil { 716 - log.Println("failed to create client to ", f.Knot) 717 return 718 } 719 720 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 721 if err != nil { 722 - log.Printf("failed to make request to %s: %s", f.Knot, err) 723 return 724 } 725 726 if ksResp.StatusCode != http.StatusNoContent { 727 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 728 return 729 } 730 731 tx, err := rp.db.BeginTx(r.Context(), nil) 732 if err != nil { 733 - log.Println("failed to start tx") 734 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 735 return 736 } 737 defer func() { 738 tx.Rollback() 739 err = rp.enforcer.E.LoadPolicy() 740 if err != nil { 741 - log.Println("failed to rollback policies") 742 } 743 }() 744 745 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 746 if err != nil { 747 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 748 return 749 } 750 751 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 752 if err != nil { 753 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 754 return 755 } 756 757 err = tx.Commit() 758 if err != nil { 759 - log.Println("failed to commit changes", err) 760 - http.Error(w, err.Error(), http.StatusInternalServerError) 761 return 762 } 763 764 err = rp.enforcer.E.SavePolicy() 765 if err != nil { 766 - log.Println("failed to update ACLs", err) 767 - http.Error(w, err.Error(), http.StatusInternalServerError) 768 return 769 } 770 771 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 772 - 773 } 774 775 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 921 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 922 } 923 924 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 925 f, err := rp.repoResolver.Resolve(r) 926 if err != nil { 927 log.Println("failed to get repo and knot", err) 928 return 929 } 930 931 switch r.Method { 932 - case http.MethodGet: 933 - // for now, this is just pubkeys 934 - user := rp.oauth.GetUser(r) 935 - repoCollaborators, err := f.Collaborators(r.Context()) 936 - if err != nil { 937 - log.Println("failed to get collaborators", err) 938 - } 939 940 - isCollaboratorInviteAllowed := false 941 - if user != nil { 942 - ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 943 - if err == nil && ok { 944 - isCollaboratorInviteAllowed = true 945 - } 946 } 947 948 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 949 if err != nil { 950 - log.Println("failed to create unsigned client", err) 951 return 952 } 953 954 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 955 if err != nil { 956 - log.Println("failed to reach knotserver", err) 957 return 958 } 959 960 - // all spindles that this user is a member of 961 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 962 - if err != nil { 963 - log.Println("failed to fetch spindles", err) 964 - return 965 } 966 967 - rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 968 - LoggedInUser: user, 969 - RepoInfo: f.RepoInfo(user), 970 - Collaborators: repoCollaborators, 971 - IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 972 - Branches: result.Branches, 973 - Spindles: spindles, 974 - CurrentSpindle: f.Spindle, 975 }) 976 } 977 } 978 979 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1093 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1094 sourceAt := f.RepoAt.String() 1095 1096 - rkey := appview.TID() 1097 repo := &db.Repo{ 1098 Did: user.Did, 1099 Name: forkName, ··· 1218 return 1219 } 1220 branches := result.Branches 1221 - sort.Slice(branches, func(i int, j int) bool { 1222 - return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1223 - }) 1224 1225 var defaultBranch string 1226 for _, b := range branches { ··· 1267 if err != nil { 1268 log.Println("failed to get repo and knot", err) 1269 return 1270 } 1271 1272 // if user is navigating to one of ··· 1331 Base: base, 1332 Head: head, 1333 Diff: &diff, 1334 }) 1335 1336 }
··· 8 "fmt" 9 "io" 10 "log" 11 + "log/slog" 12 "net/http" 13 "net/url" 14 + "path/filepath" 15 "slices" 16 "strconv" 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/commitverify" 22 "tangled.sh/tangled.sh/core/appview/config" 23 "tangled.sh/tangled.sh/core/appview/db" 24 + "tangled.sh/tangled.sh/core/appview/notify" 25 "tangled.sh/tangled.sh/core/appview/oauth" 26 "tangled.sh/tangled.sh/core/appview/pages" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 + "tangled.sh/tangled.sh/core/idresolver" 31 "tangled.sh/tangled.sh/core/knotclient" 32 "tangled.sh/tangled.sh/core/patchutil" 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 35 "tangled.sh/tangled.sh/core/types" 36 37 securejoin "github.com/cyphar/filepath-securejoin" 38 "github.com/go-chi/chi/v5" 39 "github.com/go-git/go-git/v5/plumbing" 40 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 lexutil "github.com/bluesky-social/indigo/lex/util" 44 ) 45 ··· 52 spindlestream *eventconsumer.Consumer 53 db *db.DB 54 enforcer *rbac.Enforcer 55 + notifier notify.Notifier 56 + logger *slog.Logger 57 } 58 59 func New( ··· 64 idResolver *idresolver.Resolver, 65 db *db.DB, 66 config *config.Config, 67 + notifier notify.Notifier, 68 enforcer *rbac.Enforcer, 69 + logger *slog.Logger, 70 ) *Repo { 71 return &Repo{oauth: oauth, 72 repoResolver: repoResolver, ··· 75 config: config, 76 spindlestream: spindlestream, 77 db: db, 78 + notifier: notifier, 79 enforcer: enforcer, 80 + logger: logger, 81 } 82 } 83 ··· 110 return 111 } 112 113 + tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 114 if err != nil { 115 log.Println("failed to reach knotserver", err) 116 return 117 } 118 119 tagMap := make(map[string][]string) 120 + for _, tag := range tagResult.Tags { 121 hash := tag.Hash 122 if tag.Tag != nil { 123 hash = tag.Tag.Target.String() ··· 125 tagMap[hash] = append(tagMap[hash], tag.Name) 126 } 127 128 + branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 129 + if err != nil { 130 + log.Println("failed to reach knotserver", err) 131 + return 132 + } 133 + 134 + for _, branch := range branchResult.Branches { 135 + hash := branch.Hash 136 + tagMap[hash] = append(tagMap[hash], branch.Name) 137 + } 138 + 139 user := rp.oauth.GetUser(r) 140 141 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) ··· 169 VerifiedCommits: vc, 170 Pipelines: pipelines, 171 }) 172 } 173 174 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { ··· 183 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 184 RepoInfo: f.RepoInfo(user), 185 }) 186 } 187 188 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 281 protocol = "https" 282 } 283 284 + var diffOpts types.DiffOpts 285 + if d := r.URL.Query().Get("diff"); d == "split" { 286 + diffOpts.Split = true 287 + } 288 + 289 if !plumbing.IsHash(ref) { 290 rp.pages.Error404(w) 291 return ··· 339 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 340 VerifiedCommit: vc, 341 Pipeline: pipeline, 342 + DiffOpts: diffOpts, 343 }) 344 } 345 346 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 377 378 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 379 // so we can safely redirect to the "parent" (which is the same file). 380 + unescapedTreePath, _ := url.PathUnescape(treePath) 381 + if len(result.Files) == 0 && result.Parent == unescapedTreePath { 382 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 383 return 384 } ··· 393 } 394 } 395 396 + sortFiles(result.Files) 397 398 rp.pages.RepoTree(w, pages.RepoTreeParams{ 399 LoggedInUser: user, 400 BreadCrumbs: breadcrumbs, 401 + TreePath: treePath, 402 RepoInfo: f.RepoInfo(user), 403 RepoTreeResponse: result, 404 }) 405 } 406 407 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 459 ArtifactMap: artifactMap, 460 DanglingArtifacts: danglingArtifacts, 461 }) 462 } 463 464 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 480 return 481 } 482 483 + sortBranches(result.Branches) 484 485 user := rp.oauth.GetUser(r) 486 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 488 RepoInfo: f.RepoInfo(user), 489 RepoBranchesResponse: *result, 490 }) 491 } 492 493 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 538 showRendered = r.URL.Query().Get("code") != "true" 539 } 540 541 + var unsupported bool 542 + var isImage bool 543 + var isVideo bool 544 + var contentSrc string 545 + 546 + if result.IsBinary { 547 + ext := strings.ToLower(filepath.Ext(result.Path)) 548 + switch ext { 549 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 550 + isImage = true 551 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 552 + isVideo = true 553 + default: 554 + unsupported = true 555 + } 556 + 557 + // fetch the actual binary content like in RepoBlobRaw 558 + 559 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 560 + contentSrc = blobURL 561 + if !rp.config.Core.Dev { 562 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 563 + } 564 + } 565 + 566 user := rp.oauth.GetUser(r) 567 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 568 LoggedInUser: user, ··· 571 BreadCrumbs: breadcrumbs, 572 ShowRendered: showRendered, 573 RenderToggle: renderToggle, 574 + Unsupported: unsupported, 575 + IsImage: isImage, 576 + IsVideo: isVideo, 577 + ContentSrc: contentSrc, 578 }) 579 } 580 581 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 582 f, err := rp.repoResolver.Resolve(r) 583 if err != nil { 584 log.Println("failed to get repo and knot", err) 585 + w.WriteHeader(http.StatusBadRequest) 586 return 587 } 588 ··· 593 if !rp.config.Core.Dev { 594 protocol = "https" 595 } 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) 598 if err != nil { 599 + log.Println("failed to reach knotserver:", err) 600 + rp.pages.Error503(w) 601 return 602 } 603 + defer resp.Body.Close() 604 605 + if resp.StatusCode != http.StatusOK { 606 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 607 + w.WriteHeader(resp.StatusCode) 608 + _, _ = io.Copy(w, resp.Body) 609 return 610 } 611 612 + contentType := resp.Header.Get("Content-Type") 613 + body, err := io.ReadAll(resp.Body) 614 if err != nil { 615 + log.Printf("error reading response body from knotserver: %v", err) 616 + w.WriteHeader(http.StatusInternalServerError) 617 return 618 } 619 620 + if strings.Contains(contentType, "text/plain") { 621 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 622 + w.Write(body) 623 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 624 + w.Header().Set("Content-Type", contentType) 625 w.Write(body) 626 + } else { 627 + w.WriteHeader(http.StatusUnsupportedMediaType) 628 + w.Write([]byte("unsupported content type")) 629 return 630 } 631 } 632 633 // modify the spindle configured for this repo 634 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 635 + user := rp.oauth.GetUser(r) 636 + l := rp.logger.With("handler", "EditSpindle") 637 + l = l.With("did", user.Did) 638 + l = l.With("handle", user.Handle) 639 + 640 + errorId := "operation-error" 641 + fail := func(msg string, err error) { 642 + l.Error(msg, "err", err) 643 + rp.pages.Notice(w, errorId, msg) 644 + } 645 + 646 f, err := rp.repoResolver.Resolve(r) 647 if err != nil { 648 + fail("Failed to resolve repo. Try again later", err) 649 return 650 } 651 652 repoAt := f.RepoAt 653 rkey := repoAt.RecordKey().String() 654 if rkey == "" { 655 + fail("Failed to resolve repo. Try again later", err) 656 return 657 } 658 659 newSpindle := r.FormValue("spindle") 660 client, err := rp.oauth.AuthorizedClient(r) 661 if err != nil { 662 + fail("Failed to authorize. Try again later.", err) 663 return 664 } 665 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 671 } 672 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 676 } 677 678 // optimistic update 679 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 680 if err != nil { 681 + fail("Failed to update spindle. Try again later.", err) 682 return 683 } 684 685 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 686 if err != nil { 687 + fail("Failed to update spindle, no record found on PDS.", err) 688 return 689 } 690 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 705 }) 706 707 if err != nil { 708 + fail("Failed to update spindle, unable to save to PDS.", err) 709 return 710 } 711 ··· 715 eventconsumer.NewSpindleSource(newSpindle), 716 ) 717 718 + rp.pages.HxRefresh(w) 719 } 720 721 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 722 + user := rp.oauth.GetUser(r) 723 + l := rp.logger.With("handler", "AddCollaborator") 724 + l = l.With("did", user.Did) 725 + l = l.With("handle", user.Handle) 726 + 727 f, err := rp.repoResolver.Resolve(r) 728 if err != nil { 729 + l.Error("failed to get repo and knot", "err", err) 730 return 731 } 732 733 + errorId := "add-collaborator-error" 734 + fail := func(msg string, err error) { 735 + l.Error(msg, "err", err) 736 + rp.pages.Notice(w, errorId, msg) 737 + } 738 + 739 collaborator := r.FormValue("collaborator") 740 if collaborator == "" { 741 + fail("Invalid form.", nil) 742 return 743 } 744 + 745 + // remove a single leading `@`, to make @handle work with ResolveIdent 746 + collaborator = strings.TrimPrefix(collaborator, "@") 747 748 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 749 if err != nil { 750 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 751 + return 752 + } 753 + 754 + if collaboratorIdent.DID.String() == user.Did { 755 + fail("You seem to be adding yourself as a collaborator.", nil) 756 return 757 } 758 + l = l.With("collaborator", collaboratorIdent.Handle) 759 + l = l.With("knot", f.Knot) 760 761 + // announce this relation into the firehose, store into owners' pds 762 + client, err := rp.oauth.AuthorizedClient(r) 763 + if err != nil { 764 + fail("Failed to write to PDS.", err) 765 + return 766 + } 767 768 + // emit a record 769 + currentUser := rp.oauth.GetUser(r) 770 + rkey := tid.TID() 771 + createdAt := time.Now() 772 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 773 + Collection: tangled.RepoCollaboratorNSID, 774 + Repo: currentUser.Did, 775 + Rkey: rkey, 776 + Record: &lexutil.LexiconTypeDecoder{ 777 + Val: &tangled.RepoCollaborator{ 778 + Subject: collaboratorIdent.DID.String(), 779 + Repo: string(f.RepoAt), 780 + CreatedAt: createdAt.Format(time.RFC3339), 781 + }}, 782 + }) 783 + // invalid record 784 + if err != nil { 785 + fail("Failed to write record to PDS.", err) 786 + return 787 + } 788 + l = l.With("at-uri", resp.Uri) 789 + l.Info("wrote record to PDS") 790 + 791 + l.Info("adding to knot") 792 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 793 if err != nil { 794 + fail("Failed to add to knot.", err) 795 return 796 } 797 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 } 803 804 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 805 if err != nil { 806 + fail("Knot was unreachable.", err) 807 return 808 } 809 810 if ksResp.StatusCode != http.StatusNoContent { 811 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 812 return 813 } 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) 825 } 826 }() 827 828 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 829 if err != nil { 830 + fail("Failed to add collaborator permissions.", err) 831 return 832 } 833 834 + err = db.AddCollaborator(rp.db, db.Collaborator{ 835 + Did: syntax.DID(currentUser.Did), 836 + Rkey: rkey, 837 + SubjectDid: collaboratorIdent.DID, 838 + RepoAt: f.RepoAt, 839 + Created: createdAt, 840 + }) 841 if err != nil { 842 + fail("Failed to add collaborator.", err) 843 return 844 } 845 846 err = tx.Commit() 847 if err != nil { 848 + fail("Failed to add collaborator.", err) 849 return 850 } 851 852 err = rp.enforcer.E.SavePolicy() 853 if err != nil { 854 + fail("Failed to update collaborator permissions.", err) 855 return 856 } 857 858 + rp.pages.HxRefresh(w) 859 } 860 861 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 1007 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1008 } 1009 1010 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1011 + user := rp.oauth.GetUser(r) 1012 + l := rp.logger.With("handler", "Secrets") 1013 + l = l.With("handle", user.Handle) 1014 + l = l.With("did", user.Did) 1015 + 1016 f, err := rp.repoResolver.Resolve(r) 1017 if err != nil { 1018 log.Println("failed to get repo and knot", err) 1019 return 1020 } 1021 1022 + if f.Spindle == "" { 1023 + log.Println("empty spindle cannot add/rm secret", err) 1024 + return 1025 + } 1026 + 1027 + lxm := tangled.RepoAddSecretNSID 1028 + if r.Method == http.MethodDelete { 1029 + lxm = tangled.RepoRemoveSecretNSID 1030 + } 1031 + 1032 + spindleClient, err := rp.oauth.ServiceClient( 1033 + r, 1034 + oauth.WithService(f.Spindle), 1035 + oauth.WithLxm(lxm), 1036 + oauth.WithDev(rp.config.Core.Dev), 1037 + ) 1038 + if err != nil { 1039 + log.Println("failed to create spindle client", err) 1040 + return 1041 + } 1042 + 1043 + key := r.FormValue("key") 1044 + if key == "" { 1045 + w.WriteHeader(http.StatusBadRequest) 1046 + return 1047 + } 1048 + 1049 switch r.Method { 1050 + case http.MethodPut: 1051 + errorId := "add-secret-error" 1052 1053 + value := r.FormValue("value") 1054 + if value == "" { 1055 + w.WriteHeader(http.StatusBadRequest) 1056 + return 1057 } 1058 1059 + err = tangled.RepoAddSecret( 1060 + r.Context(), 1061 + spindleClient, 1062 + &tangled.RepoAddSecret_Input{ 1063 + Repo: f.RepoAt.String(), 1064 + Key: key, 1065 + Value: value, 1066 + }, 1067 + ) 1068 if err != nil { 1069 + l.Error("Failed to add secret.", "err", err) 1070 + rp.pages.Notice(w, errorId, "Failed to add secret.") 1071 return 1072 } 1073 1074 + case http.MethodDelete: 1075 + errorId := "operation-error" 1076 + 1077 + err = tangled.RepoRemoveSecret( 1078 + r.Context(), 1079 + spindleClient, 1080 + &tangled.RepoRemoveSecret_Input{ 1081 + Repo: f.RepoAt.String(), 1082 + Key: key, 1083 + }, 1084 + ) 1085 if err != nil { 1086 + l.Error("Failed to delete secret.", "err", err) 1087 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 1088 return 1089 } 1090 + } 1091 1092 + rp.pages.HxRefresh(w) 1093 + } 1094 + 1095 + type tab = map[string]any 1096 + 1097 + var ( 1098 + // would be great to have ordered maps right about now 1099 + settingsTabs []tab = []tab{ 1100 + {"Name": "general", "Icon": "sliders-horizontal"}, 1101 + {"Name": "access", "Icon": "users"}, 1102 + {"Name": "pipelines", "Icon": "layers-2"}, 1103 + } 1104 + ) 1105 + 1106 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1107 + tabVal := r.URL.Query().Get("tab") 1108 + if tabVal == "" { 1109 + tabVal = "general" 1110 + } 1111 + 1112 + switch tabVal { 1113 + case "general": 1114 + rp.generalSettings(w, r) 1115 + 1116 + case "access": 1117 + rp.accessSettings(w, r) 1118 + 1119 + case "pipelines": 1120 + rp.pipelineSettings(w, r) 1121 + } 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 + } 1183 + 1184 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1185 + f, err := rp.repoResolver.Resolve(r) 1186 + user := rp.oauth.GetUser(r) 1187 + 1188 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1189 + if err != nil { 1190 + log.Println("failed to create unsigned client", err) 1191 + return 1192 + } 1193 + 1194 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1195 + if err != nil { 1196 + log.Println("failed to reach knotserver", err) 1197 + return 1198 + } 1199 + 1200 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1201 + LoggedInUser: user, 1202 + RepoInfo: f.RepoInfo(user), 1203 + Branches: result.Branches, 1204 + Tabs: settingsTabs, 1205 + Tab: "general", 1206 + }) 1207 + } 1208 + 1209 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1210 + f, err := rp.repoResolver.Resolve(r) 1211 + user := rp.oauth.GetUser(r) 1212 + 1213 + repoCollaborators, err := f.Collaborators(r.Context()) 1214 + if err != nil { 1215 + log.Println("failed to get collaborators", err) 1216 + } 1217 + 1218 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1219 + LoggedInUser: user, 1220 + RepoInfo: f.RepoInfo(user), 1221 + Tabs: settingsTabs, 1222 + Tab: "access", 1223 + Collaborators: repoCollaborators, 1224 + }) 1225 + } 1226 + 1227 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1228 + f, err := rp.repoResolver.Resolve(r) 1229 + user := rp.oauth.GetUser(r) 1230 + 1231 + // all spindles that the repo owner is a member of 1232 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1233 + if err != nil { 1234 + log.Println("failed to fetch spindles", err) 1235 + return 1236 + } 1237 + 1238 + var secrets []*tangled.RepoListSecrets_Secret 1239 + if f.Spindle != "" { 1240 + if spindleClient, err := rp.oauth.ServiceClient( 1241 + r, 1242 + oauth.WithService(f.Spindle), 1243 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1244 + oauth.WithDev(rp.config.Core.Dev), 1245 + ); err != nil { 1246 + log.Println("failed to create spindle client", err) 1247 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1248 + log.Println("failed to fetch secrets", err) 1249 + } else { 1250 + secrets = resp.Secrets 1251 } 1252 + } 1253 1254 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1255 + return strings.Compare(a.Key, b.Key) 1256 + }) 1257 + 1258 + var dids []string 1259 + for _, s := range secrets { 1260 + dids = append(dids, s.CreatedBy) 1261 + } 1262 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1263 + 1264 + // convert to a more manageable form 1265 + var niceSecret []map[string]any 1266 + for id, s := range secrets { 1267 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1268 + niceSecret = append(niceSecret, map[string]any{ 1269 + "Id": id, 1270 + "Key": s.Key, 1271 + "CreatedAt": when, 1272 + "CreatedBy": resolvedIdents[id].Handle.String(), 1273 }) 1274 } 1275 + 1276 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1277 + LoggedInUser: user, 1278 + RepoInfo: f.RepoInfo(user), 1279 + Tabs: settingsTabs, 1280 + Tab: "pipelines", 1281 + Spindles: spindles, 1282 + CurrentSpindle: f.Spindle, 1283 + Secrets: niceSecret, 1284 + }) 1285 } 1286 1287 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1401 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1402 sourceAt := f.RepoAt.String() 1403 1404 + rkey := tid.TID() 1405 repo := &db.Repo{ 1406 Did: user.Did, 1407 Name: forkName, ··· 1526 return 1527 } 1528 branches := result.Branches 1529 + 1530 + sortBranches(branches) 1531 1532 var defaultBranch string 1533 for _, b := range branches { ··· 1574 if err != nil { 1575 log.Println("failed to get repo and knot", err) 1576 return 1577 + } 1578 + 1579 + var diffOpts types.DiffOpts 1580 + if d := r.URL.Query().Get("diff"); d == "split" { 1581 + diffOpts.Split = true 1582 } 1583 1584 // if user is navigating to one of ··· 1643 Base: base, 1644 Head: head, 1645 Diff: &diff, 1646 + DiffOpts: diffOpts, 1647 }) 1648 1649 }
+34
appview/repo/repo_util.go
··· 5 "crypto/rand" 6 "fmt" 7 "math/big" 8 9 "tangled.sh/tangled.sh/core/appview/db" 10 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 11 12 "github.com/go-git/go-git/v5/plumbing/object" 13 ) 14 15 func uniqueEmails(commits []*object.Commit) []string { 16 emails := make(map[string]struct{})
··· 5 "crypto/rand" 6 "fmt" 7 "math/big" 8 + "slices" 9 + "sort" 10 + "strings" 11 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 + "tangled.sh/tangled.sh/core/types" 15 16 "github.com/go-git/go-git/v5/plumbing/object" 17 ) 18 + 19 + func sortFiles(files []types.NiceTree) { 20 + sort.Slice(files, func(i, j int) bool { 21 + iIsFile := files[i].IsFile 22 + jIsFile := files[j].IsFile 23 + if iIsFile != jIsFile { 24 + return !iIsFile 25 + } 26 + return files[i].Name < files[j].Name 27 + }) 28 + } 29 + 30 + func sortBranches(branches []types.Branch) { 31 + slices.SortFunc(branches, func(a, b types.Branch) int { 32 + if a.IsDefault { 33 + return -1 34 + } 35 + if b.IsDefault { 36 + return 1 37 + } 38 + if a.Commit != nil && b.Commit != nil { 39 + if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 40 + return 1 41 + } else { 42 + return -1 43 + } 44 + } 45 + return strings.Compare(a.Name, b.Name) 46 + }) 47 + } 48 49 func uniqueEmails(commits []*object.Commit) []string { 50 emails := make(map[string]struct{})
+2
appview/repo/router.go
··· 74 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 r.Put("/branches/default", rp.SetDefaultBranch) 77 }) 78 }) 79
··· 74 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 r.Put("/branches/default", rp.SetDefaultBranch) 77 + r.Put("/secrets", rp.Secrets) 78 + r.Delete("/secrets", rp.Secrets) 79 }) 80 }) 81
+5 -4
appview/reporesolver/resolver.go
··· 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) ··· 149 for _, item := range repoCollaborators { 150 // currently only two roles: owner and member 151 var role string 152 - if item[3] == "repo:owner" { 153 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 155 role = "collaborator" 156 - } else { 157 continue 158 } 159
··· 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) ··· 149 for _, item := range repoCollaborators { 150 // currently only two roles: owner and member 151 var role string 152 + switch item[3] { 153 + case "repo:owner": 154 role = "owner" 155 + case "repo:collaborator": 156 role = "collaborator" 157 + default: 158 continue 159 } 160
+2 -2
appview/settings/settings.go
··· 12 13 "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/email" 19 "tangled.sh/tangled.sh/core/appview/middleware" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 return 367 } 368 369 - rkey := appview.TID() 370 371 tx, err := s.Db.Begin() 372 if err != nil {
··· 12 13 "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 "tangled.sh/tangled.sh/core/appview/email" 18 "tangled.sh/tangled.sh/core/appview/middleware" 19 "tangled.sh/tangled.sh/core/appview/oauth" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/tid" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 return 367 } 368 369 + rkey := tid.TID() 370 371 tx, err := s.Db.Begin() 372 if err != nil {
+104
appview/signup/requests.go
···
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
+249
appview/signup/signup.go
···
··· 1 + package signup 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/posthog/posthog-go" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/dns" 16 + "tangled.sh/tangled.sh/core/appview/email" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/state/userutil" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + ) 22 + 23 + type Signup struct { 24 + config *config.Config 25 + db *db.DB 26 + cf *dns.Cloudflare 27 + posthog posthog.Client 28 + xrpc *xrpcclient.Client 29 + idResolver *idresolver.Resolver 30 + pages *pages.Pages 31 + l *slog.Logger 32 + disallowedNicknames map[string]bool 33 + } 34 + 35 + func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 36 + var cf *dns.Cloudflare 37 + if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 38 + var err error 39 + cf, err = dns.NewCloudflare(cfg) 40 + if err != nil { 41 + l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 42 + } 43 + } 44 + 45 + disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + 47 + return &Signup{ 48 + config: cfg, 49 + db: database, 50 + posthog: pc, 51 + idResolver: idResolver, 52 + cf: cf, 53 + pages: pages, 54 + l: l, 55 + disallowedNicknames: disallowedNicknames, 56 + } 57 + } 58 + 59 + func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 60 + disallowed := make(map[string]bool) 61 + 62 + if filepath == "" { 63 + logger.Debug("no disallowed nicknames file configured") 64 + return disallowed 65 + } 66 + 67 + file, err := os.Open(filepath) 68 + if err != nil { 69 + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 70 + return disallowed 71 + } 72 + defer file.Close() 73 + 74 + scanner := bufio.NewScanner(file) 75 + lineNum := 0 76 + for scanner.Scan() { 77 + lineNum++ 78 + line := strings.TrimSpace(scanner.Text()) 79 + if line == "" || strings.HasPrefix(line, "#") { 80 + continue // skip empty lines and comments 81 + } 82 + 83 + nickname := strings.ToLower(line) 84 + if userutil.IsValidSubdomain(nickname) { 85 + disallowed[nickname] = true 86 + } else { 87 + logger.Warn("invalid nickname format in disallowed nicknames file", 88 + "file", filepath, "line", lineNum, "nickname", nickname) 89 + } 90 + } 91 + 92 + if err := scanner.Err(); err != nil { 93 + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 94 + } 95 + 96 + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 97 + return disallowed 98 + } 99 + 100 + // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 101 + func (s *Signup) isNicknameAllowed(nickname string) bool { 102 + return !s.disallowedNicknames[strings.ToLower(nickname)] 103 + } 104 + 105 + func (s *Signup) Router() http.Handler { 106 + r := chi.NewRouter() 107 + r.Post("/", s.signup) 108 + r.Get("/complete", s.complete) 109 + r.Post("/complete", s.complete) 110 + 111 + return r 112 + } 113 + 114 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 115 + if s.cf == nil { 116 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 117 + } 118 + emailId := r.FormValue("email") 119 + 120 + if !email.IsValidEmail(emailId) { 121 + s.pages.Notice(w, "login-msg", "Invalid email address.") 122 + return 123 + } 124 + 125 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 126 + if err != nil { 127 + s.l.Error("failed to check email existence", "error", err) 128 + s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 129 + return 130 + } 131 + if exists { 132 + s.pages.Notice(w, "login-msg", "Email already exists.") 133 + return 134 + } 135 + 136 + code, err := s.inviteCodeRequest() 137 + if err != nil { 138 + s.l.Error("failed to create invite code", "error", err) 139 + s.pages.Notice(w, "login-msg", "Failed to create invite code.") 140 + return 141 + } 142 + 143 + em := email.Email{ 144 + APIKey: s.config.Resend.ApiKey, 145 + From: s.config.Resend.SentFrom, 146 + To: emailId, 147 + Subject: "Verify your Tangled account", 148 + Text: `Copy and paste this code below to verify your account on Tangled. 149 + ` + code, 150 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 151 + <p><code>` + code + `</code></p>`, 152 + } 153 + 154 + err = email.SendEmail(em) 155 + if err != nil { 156 + s.l.Error("failed to send email", "error", err) 157 + s.pages.Notice(w, "login-msg", "Failed to send email.") 158 + return 159 + } 160 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 161 + Email: emailId, 162 + InviteCode: code, 163 + }) 164 + if err != nil { 165 + s.l.Error("failed to add inflight signup", "error", err) 166 + s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 167 + return 168 + } 169 + 170 + s.pages.HxRedirect(w, "/signup/complete") 171 + } 172 + 173 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 174 + switch r.Method { 175 + case http.MethodGet: 176 + s.pages.CompleteSignup(w, pages.SignupParams{}) 177 + case http.MethodPost: 178 + username := r.FormValue("username") 179 + password := r.FormValue("password") 180 + code := r.FormValue("code") 181 + 182 + if !userutil.IsValidSubdomain(username) { 183 + s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ€“63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.") 184 + return 185 + } 186 + 187 + if !s.isNicknameAllowed(username) { 188 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 189 + return 190 + } 191 + 192 + email, err := db.GetEmailForCode(s.db, code) 193 + if err != nil { 194 + s.l.Error("failed to get email for code", "error", err) 195 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 196 + return 197 + } 198 + 199 + did, err := s.createAccountRequest(username, password, email, code) 200 + if err != nil { 201 + s.l.Error("failed to create account", "error", err) 202 + s.pages.Notice(w, "signup-error", err.Error()) 203 + return 204 + } 205 + 206 + if s.cf == nil { 207 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 208 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 209 + return 210 + } 211 + 212 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 213 + Type: "TXT", 214 + Name: "_atproto." + username, 215 + Content: "did=" + did, 216 + TTL: 6400, 217 + Proxied: false, 218 + }) 219 + if err != nil { 220 + s.l.Error("failed to create DNS record", "error", err) 221 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 222 + return 223 + } 224 + 225 + err = db.AddEmail(s.db, db.Email{ 226 + Did: did, 227 + Address: email, 228 + Verified: true, 229 + Primary: true, 230 + }) 231 + if err != nil { 232 + s.l.Error("failed to add email", "error", err) 233 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 234 + return 235 + } 236 + 237 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 238 + <a class="underline text-black dark:text-white" href="/login">login</a> 239 + with <code>%s.tngl.sh</code>.`, username)) 240 + 241 + go func() { 242 + err := db.DeleteInflightSignup(s.db, email) 243 + if err != nil { 244 + s.l.Error("failed to delete inflight signup", "error", err) 245 + } 246 + }() 247 + return 248 + } 249 + }
+26 -14
appview/spindles/spindles.go
··· 10 11 "github.com/go-chi/chi/v5" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 "tangled.sh/tangled.sh/core/appview/config" 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/core/appview/middleware" 18 "tangled.sh/tangled.sh/core/appview/oauth" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 21 "tangled.sh/tangled.sh/core/rbac" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 104 105 repos, err := db.GetRepos( 106 s.Db, 107 db.FilterEq("spindle", instance), 108 ) 109 if err != nil { ··· 113 } 114 115 identsToResolve := make([]string, len(members)) 116 - for i, member := range members { 117 - identsToResolve[i] = member 118 - } 119 resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 120 didHandleMap := make(map[string]string) 121 for _, identity := range resolvedIds { ··· 257 258 // ok 259 s.Pages.HxRefresh(w) 260 - return 261 } 262 263 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 305 s.Enforcer.E.LoadPolicy() 306 }() 307 308 - err = db.DeleteSpindle( 309 tx, 310 - db.FilterEq("owner", user.Did), 311 db.FilterEq("instance", instance), 312 ) 313 if err != nil { 314 - l.Error("failed to delete spindle", "err", err) 315 fail() 316 return 317 } 318 319 - err = s.Enforcer.RemoveSpindle(instance) 320 if err != nil { 321 - l.Error("failed to update ACL", "err", err) 322 fail() 323 return 324 } 325 326 client, err := s.OAuth.AuthorizedClient(r) 327 if err != nil { 328 l.Error("failed to authorize client", "err", err) ··· 520 s.Enforcer.E.LoadPolicy() 521 }() 522 523 - rkey := appview.TID() 524 525 // add member to db 526 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 579 l := s.Logger.With("handler", "removeMember") 580 581 noticeId := "operation-error" 582 - defaultErr := "Failed to add member. Try again later." 583 fail := func() { 584 s.Pages.Notice(w, noticeId, defaultErr) 585 } ··· 707 708 // ok 709 s.Pages.HxRefresh(w) 710 - return 711 }
··· 10 11 "github.com/go-chi/chi/v5" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/appview/config" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 + "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 + "tangled.sh/tangled.sh/core/tid" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 104 105 repos, err := db.GetRepos( 106 s.Db, 107 + 0, 108 db.FilterEq("spindle", instance), 109 ) 110 if err != nil { ··· 114 } 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 { ··· 256 257 // ok 258 s.Pages.HxRefresh(w) 259 } 260 261 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 303 s.Enforcer.E.LoadPolicy() 304 }() 305 306 + // remove spindle members first 307 + err = db.RemoveSpindleMember( 308 tx, 309 + db.FilterEq("did", user.Did), 310 db.FilterEq("instance", instance), 311 ) 312 if err != nil { 313 + l.Error("failed to remove spindle members", "err", err) 314 fail() 315 return 316 } 317 318 + err = db.DeleteSpindle( 319 + tx, 320 + db.FilterEq("owner", user.Did), 321 + db.FilterEq("instance", instance), 322 + ) 323 if err != nil { 324 + l.Error("failed to delete spindle", "err", err) 325 fail() 326 return 327 } 328 329 + // delete from enforcer 330 + if spindles[0].Verified != nil { 331 + err = s.Enforcer.RemoveSpindle(instance) 332 + if err != nil { 333 + l.Error("failed to update ACL", "err", err) 334 + fail() 335 + return 336 + } 337 + } 338 + 339 client, err := s.OAuth.AuthorizedClient(r) 340 if err != nil { 341 l.Error("failed to authorize client", "err", err) ··· 533 s.Enforcer.E.LoadPolicy() 534 }() 535 536 + rkey := tid.TID() 537 538 // add member to db 539 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 592 l := s.Logger.With("handler", "removeMember") 593 594 noticeId := "operation-error" 595 + defaultErr := "Failed to remove member. Try again later." 596 fail := func() { 597 s.Pages.Notice(w, noticeId, defaultErr) 598 } ··· 720 721 // ok 722 s.Pages.HxRefresh(w) 723 }
+13 -26
appview/state/follow.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/posthog/posthog-go" 11 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/pages" 15 ) 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 switch r.Method { 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 - rkey := appview.TID() 46 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, ··· 58 return 59 } 60 61 - err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey) 62 if err != nil { 63 log.Println("failed to follow", err) 64 return 65 } 66 67 - log.Println("created atproto record: ", resp.Uri) 68 69 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 70 UserDid: subjectIdent.DID.String(), 71 FollowStatus: db.IsFollowing, 72 }) 73 74 - if !s.config.Core.Dev { 75 - err = s.posthog.Enqueue(posthog.Capture{ 76 - DistinctId: currentUser.Did, 77 - Event: "follow", 78 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 79 - }) 80 - if err != nil { 81 - log.Println("failed to enqueue posthog event:", err) 82 - } 83 - } 84 - 85 return 86 case http.MethodDelete: 87 // find the record in the db ··· 113 FollowStatus: db.IsNotFollowing, 114 }) 115 116 - if !s.config.Core.Dev { 117 - err = s.posthog.Enqueue(posthog.Capture{ 118 - DistinctId: currentUser.Did, 119 - Event: "unfollow", 120 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 121 - }) 122 - if err != nil { 123 - log.Println("failed to enqueue posthog event:", err) 124 - } 125 - } 126 127 return 128 }
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 + "tangled.sh/tangled.sh/core/tid" 14 ) 15 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 41 switch r.Method { 42 case http.MethodPost: 43 createdAt := time.Now().Format(time.RFC3339) 44 + rkey := tid.TID() 45 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 Collection: tangled.GraphFollowNSID, 47 Repo: currentUser.Did, ··· 57 return 58 } 59 60 + log.Println("created atproto record: ", resp.Uri) 61 + 62 + follow := &db.Follow{ 63 + UserDid: currentUser.Did, 64 + SubjectDid: subjectIdent.DID.String(), 65 + Rkey: rkey, 66 + } 67 + 68 + err = db.AddFollow(s.db, follow) 69 if err != nil { 70 log.Println("failed to follow", err) 71 return 72 } 73 74 + s.notifier.NewFollow(r.Context(), follow) 75 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 UserDid: subjectIdent.DID.String(), 78 FollowStatus: db.IsFollowing, 79 }) 80 81 return 82 case http.MethodDelete: 83 // find the record in the db ··· 109 FollowStatus: db.IsNotFollowing, 110 }) 111 112 + s.notifier.DeleteFollow(r.Context(), follow) 113 114 return 115 }
+75 -12
appview/state/knotstream.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "slices" 8 "time" ··· 18 "tangled.sh/tangled.sh/core/workflow" 19 20 "github.com/bluesky-social/indigo/atproto/syntax" 21 "github.com/posthog/posthog-go" 22 ) 23 ··· 39 40 cfg := ec.ConsumerConfig{ 41 Sources: srcs, 42 - ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev), 43 RetryInterval: c.Knotstream.RetryInterval, 44 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 45 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 53 return ec.NewConsumer(cfg), nil 54 } 55 56 - func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 57 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 58 switch msg.Nsid { 59 case tangled.GitRefUpdateNSID: ··· 81 return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key()) 82 } 83 84 knownEmails, err := db.GetAllEmails(d, record.CommitterDid) 85 if err != nil { 86 return err 87 } 88 count := 0 89 for _, ke := range knownEmails { 90 if record.Meta == nil { ··· 108 Date: time.Now(), 109 Count: count, 110 } 111 - if err := db.AddPunch(d, punch); err != nil { 112 - return err 113 } 114 115 - if !dev { 116 - err = pc.Enqueue(posthog.Capture{ 117 - DistinctId: record.CommitterDid, 118 - Event: "git_ref_update", 119 }) 120 - if err != nil { 121 - // non-fatal, TODO: log this 122 - } 123 } 124 125 - return nil 126 } 127 128 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 138 139 if record.TriggerMetadata.Repo == nil { 140 return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 141 } 142 143 // trigger info
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 "slices" 9 "time" ··· 19 "tangled.sh/tangled.sh/core/workflow" 20 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 + "github.com/go-git/go-git/v5/plumbing" 23 "github.com/posthog/posthog-go" 24 ) 25 ··· 41 42 cfg := ec.ConsumerConfig{ 43 Sources: srcs, 44 + ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev), 45 RetryInterval: c.Knotstream.RetryInterval, 46 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 47 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 55 return ec.NewConsumer(cfg), nil 56 } 57 58 + func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 59 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 60 switch msg.Nsid { 61 case tangled.GitRefUpdateNSID: ··· 83 return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key()) 84 } 85 86 + err1 := populatePunchcard(d, record) 87 + err2 := updateRepoLanguages(d, record) 88 + 89 + var err3 error 90 + if !dev { 91 + err3 = pc.Enqueue(posthog.Capture{ 92 + DistinctId: record.CommitterDid, 93 + Event: "git_ref_update", 94 + }) 95 + } 96 + 97 + return errors.Join(err1, err2, err3) 98 + } 99 + 100 + func populatePunchcard(d *db.DB, record tangled.GitRefUpdate) error { 101 knownEmails, err := db.GetAllEmails(d, record.CommitterDid) 102 if err != nil { 103 return err 104 } 105 + 106 count := 0 107 for _, ke := range knownEmails { 108 if record.Meta == nil { ··· 126 Date: time.Now(), 127 Count: count, 128 } 129 + return db.AddPunch(d, punch) 130 + } 131 + 132 + func updateRepoLanguages(d *db.DB, record tangled.GitRefUpdate) error { 133 + if record.Meta == nil || record.Meta.LangBreakdown == nil || record.Meta.LangBreakdown.Inputs == nil { 134 + return fmt.Errorf("empty language data for repo: %s/%s", record.RepoDid, record.RepoName) 135 + } 136 + 137 + repos, err := db.GetRepos( 138 + d, 139 + 0, 140 + db.FilterEq("did", record.RepoDid), 141 + db.FilterEq("name", record.RepoName), 142 + ) 143 + if err != nil { 144 + return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) 145 + } 146 + if len(repos) != 1 { 147 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 148 + } 149 + repo := repos[0] 150 + 151 + ref := plumbing.ReferenceName(record.Ref) 152 + if !ref.IsBranch() { 153 + return fmt.Errorf("%s is not a valid reference name", ref) 154 } 155 156 + var langs []db.RepoLanguage 157 + for _, l := range record.Meta.LangBreakdown.Inputs { 158 + if l == nil { 159 + continue 160 + } 161 + 162 + langs = append(langs, db.RepoLanguage{ 163 + RepoAt: repo.RepoAt(), 164 + Ref: ref.Short(), 165 + IsDefaultRef: record.Meta.IsDefaultRef, 166 + Language: l.Lang, 167 + Bytes: l.Size, 168 }) 169 } 170 171 + return db.InsertRepoLanguages(d, langs) 172 } 173 174 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 184 185 if record.TriggerMetadata.Repo == nil { 186 return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 187 + } 188 + 189 + // does this repo have a spindle configured? 190 + repos, err := db.GetRepos( 191 + d, 192 + 0, 193 + db.FilterEq("did", record.TriggerMetadata.Repo.Did), 194 + db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 195 + ) 196 + if err != nil { 197 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 198 + } 199 + if len(repos) != 1 { 200 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 201 + } 202 + if repos[0].Spindle == "" { 203 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 204 } 205 206 // trigger info
+12 -15
appview/state/profile.go
··· 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages" ··· 50 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 51 } 52 53 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 54 if err != nil { 55 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 56 } ··· 171 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 172 } 173 174 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 175 if err != nil { 176 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 177 } ··· 192 s.pages.ReposPage(w, pages.ReposPageParams{ 193 LoggedInUser: loggedInUser, 194 Repos: repos, 195 Card: pages.ProfileCard{ 196 UserDid: ident.DID.String(), 197 UserHandle: ident.Handle.String(), ··· 257 } 258 259 s.updateProfile(profile, w, r) 260 - return 261 } 262 263 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 297 profile.PinnedRepos = pinnedRepos 298 299 s.updateProfile(profile, w, r) 300 - return 301 } 302 303 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 362 return 363 } 364 365 - if !s.config.Core.Dev { 366 - err = s.posthog.Enqueue(posthog.Capture{ 367 - DistinctId: user.Did, 368 - Event: "edit_profile", 369 - }) 370 - if err != nil { 371 - log.Println("failed to enqueue posthog event:", err) 372 - } 373 - } 374 375 s.pages.HxRedirect(w, "/"+user.Did) 376 - return 377 } 378 379 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
··· 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/pages" ··· 49 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 50 } 51 52 + repos, err := db.GetRepos( 53 + s.db, 54 + 0, 55 + db.FilterEq("did", ident.DID.String()), 56 + ) 57 if err != nil { 58 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 59 } ··· 174 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 175 } 176 177 + repos, err := db.GetRepos( 178 + s.db, 179 + 0, 180 + db.FilterEq("did", ident.DID.String()), 181 + ) 182 if err != nil { 183 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 184 } ··· 199 s.pages.ReposPage(w, pages.ReposPageParams{ 200 LoggedInUser: loggedInUser, 201 Repos: repos, 202 + DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 203 Card: pages.ProfileCard{ 204 UserDid: ident.DID.String(), 205 UserHandle: ident.Handle.String(), ··· 265 } 266 267 s.updateProfile(profile, w, r) 268 } 269 270 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 304 profile.PinnedRepos = pinnedRepos 305 306 s.updateProfile(profile, w, r) 307 } 308 309 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 368 return 369 } 370 371 + s.notifier.UpdateProfile(r.Context(), profile) 372 373 s.pages.HxRedirect(w, "/"+user.Did) 374 } 375 376 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+126
appview/state/reaction.go
···
··· 1 + package state 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview/db" 14 + "tangled.sh/tangled.sh/core/appview/pages" 15 + "tangled.sh/tangled.sh/core/tid" 16 + ) 17 + 18 + func (s *State) React(w http.ResponseWriter, r *http.Request) { 19 + currentUser := s.oauth.GetUser(r) 20 + 21 + subject := r.URL.Query().Get("subject") 22 + if subject == "" { 23 + log.Println("invalid form") 24 + return 25 + } 26 + 27 + subjectUri, err := syntax.ParseATURI(subject) 28 + if err != nil { 29 + log.Println("invalid form") 30 + return 31 + } 32 + 33 + reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + if !ok { 35 + log.Println("invalid reaction kind") 36 + return 37 + } 38 + 39 + client, err := s.oauth.AuthorizedClient(r) 40 + if err != nil { 41 + log.Println("failed to authorize client", err) 42 + return 43 + } 44 + 45 + switch r.Method { 46 + case http.MethodPost: 47 + createdAt := time.Now().Format(time.RFC3339) 48 + rkey := tid.TID() 49 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + Collection: tangled.FeedReactionNSID, 51 + Repo: currentUser.Did, 52 + Rkey: rkey, 53 + Record: &lexutil.LexiconTypeDecoder{ 54 + Val: &tangled.FeedReaction{ 55 + Subject: subjectUri.String(), 56 + Reaction: reactionKind.String(), 57 + CreatedAt: createdAt, 58 + }, 59 + }, 60 + }) 61 + if err != nil { 62 + log.Println("failed to create atproto record", err) 63 + return 64 + } 65 + 66 + err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 67 + if err != nil { 68 + log.Println("failed to react", err) 69 + return 70 + } 71 + 72 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + if err != nil { 74 + log.Println("failed to get reaction count for ", subjectUri) 75 + } 76 + 77 + log.Println("created atproto record: ", resp.Uri) 78 + 79 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 + IsReacted: true, 84 + }) 85 + 86 + return 87 + case http.MethodDelete: 88 + reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 89 + if err != nil { 90 + log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 91 + return 92 + } 93 + 94 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + Collection: tangled.FeedReactionNSID, 96 + Repo: currentUser.Did, 97 + Rkey: reaction.Rkey, 98 + }) 99 + 100 + if err != nil { 101 + log.Println("failed to remove reaction") 102 + return 103 + } 104 + 105 + err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 106 + if err != nil { 107 + log.Println("failed to delete reaction from DB") 108 + // this is not an issue, the firehose event might have already done this 109 + } 110 + 111 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 112 + if err != nil { 113 + log.Println("failed to get reaction count for ", subjectUri) 114 + return 115 + } 116 + 117 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 + IsReacted: false, 122 + }) 123 + 124 + return 125 + } 126 + }
+40 -22
appview/state/router.go
··· 7 "github.com/go-chi/chi/v5" 8 "github.com/gorilla/sessions" 9 "tangled.sh/tangled.sh/core/appview/issues" 10 "tangled.sh/tangled.sh/core/appview/middleware" 11 oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 12 "tangled.sh/tangled.sh/core/appview/pipelines" 13 "tangled.sh/tangled.sh/core/appview/pulls" 14 "tangled.sh/tangled.sh/core/appview/repo" 15 "tangled.sh/tangled.sh/core/appview/settings" 16 "tangled.sh/tangled.sh/core/appview/spindles" 17 "tangled.sh/tangled.sh/core/appview/state/userutil" 18 "tangled.sh/tangled.sh/core/log" ··· 101 102 r.Get("/", s.Timeline) 103 104 - r.Route("/knots", func(r chi.Router) { 105 - r.Use(middleware.AuthMiddleware(s.oauth)) 106 - r.Get("/", s.Knots) 107 - r.Post("/key", s.RegistrationKey) 108 - 109 - r.Route("/{domain}", func(r chi.Router) { 110 - r.Post("/init", s.InitKnotServer) 111 - r.Get("/", s.KnotServerInfo) 112 - r.Route("/member", func(r chi.Router) { 113 - r.Use(mw.KnotOwner()) 114 - r.Get("/", s.ListMembers) 115 - r.Put("/", s.AddMember) 116 - r.Delete("/", s.RemoveMember) 117 - }) 118 - }) 119 - }) 120 - 121 r.Route("/repo", func(r chi.Router) { 122 r.Route("/new", func(r chi.Router) { 123 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 135 r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { 136 r.Post("/", s.Star) 137 r.Delete("/", s.Star) 138 }) 139 140 r.Route("/profile", func(r chi.Router) { ··· 146 }) 147 148 r.Mount("/settings", s.SettingsRouter()) 149 r.Mount("/spindles", s.SpindlesRouter()) 150 r.Mount("/", s.OAuthRouter()) 151 152 r.Get("/keys/{user}", s.Keys) 153 154 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 155 s.pages.Error404(w) ··· 190 return spindles.Router() 191 } 192 193 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 194 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 195 return issues.Router(mw) 196 - 197 } 198 199 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 200 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 201 return pulls.Router(mw) 202 } 203 204 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 205 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 206 return repo.Router(mw) 207 } 208 209 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 210 - pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 211 return pipes.Router(mw) 212 }
··· 7 "github.com/go-chi/chi/v5" 8 "github.com/gorilla/sessions" 9 "tangled.sh/tangled.sh/core/appview/issues" 10 + "tangled.sh/tangled.sh/core/appview/knots" 11 "tangled.sh/tangled.sh/core/appview/middleware" 12 oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 13 "tangled.sh/tangled.sh/core/appview/pipelines" 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 "tangled.sh/tangled.sh/core/appview/repo" 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 "tangled.sh/tangled.sh/core/log" ··· 103 104 r.Get("/", s.Timeline) 105 106 r.Route("/repo", func(r chi.Router) { 107 r.Route("/new", func(r chi.Router) { 108 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 120 r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { 121 r.Post("/", s.Star) 122 r.Delete("/", s.Star) 123 + }) 124 + 125 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) { 126 + r.Post("/", s.React) 127 + r.Delete("/", s.React) 128 }) 129 130 r.Route("/profile", func(r chi.Router) { ··· 136 }) 137 138 r.Mount("/settings", s.SettingsRouter()) 139 + r.Mount("/knots", s.KnotsRouter(mw)) 140 r.Mount("/spindles", s.SpindlesRouter()) 141 + r.Mount("/signup", s.SignupRouter()) 142 r.Mount("/", s.OAuthRouter()) 143 144 r.Get("/keys/{user}", s.Keys) 145 + r.Get("/terms", s.TermsOfService) 146 + r.Get("/privacy", s.PrivacyPolicy) 147 148 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 149 s.pages.Error404(w) ··· 184 return spindles.Router() 185 } 186 187 + func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 188 + logger := log.New("knots") 189 + 190 + knots := &knots.Knots{ 191 + Db: s.db, 192 + OAuth: s.oauth, 193 + Pages: s.pages, 194 + Config: s.config, 195 + Enforcer: s.enforcer, 196 + IdResolver: s.idResolver, 197 + Knotstream: s.knotstream, 198 + Logger: logger, 199 + } 200 + 201 + return knots.Router(mw) 202 + } 203 + 204 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 205 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 206 return issues.Router(mw) 207 } 208 209 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 210 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 211 return pulls.Router(mw) 212 } 213 214 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 215 + logger := log.New("repo") 216 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 217 return repo.Router(mw) 218 } 219 220 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 221 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 222 return pipes.Router(mw) 223 } 224 + 225 + func (s *State) SignupRouter() http.Handler { 226 + logger := log.New("signup") 227 + 228 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 229 + return sig.Router() 230 + }
+15 -29
appview/state/star.go
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "github.com/posthog/posthog-go" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 ) 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 switch r.Method { 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 - rkey := appview.TID() 43 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, ··· 54 log.Println("failed to create atproto record", err) 55 return 56 } 57 58 - err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey) 59 if err != nil { 60 log.Println("failed to star", err) 61 return ··· 66 log.Println("failed to get star count for ", subjectUri) 67 } 68 69 - log.Println("created atproto record: ", resp.Uri) 70 71 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 72 IsStarred: true, 73 RepoAt: subjectUri, 74 Stats: db.RepoStats{ ··· 76 }, 77 }) 78 79 - if !s.config.Core.Dev { 80 - err = s.posthog.Enqueue(posthog.Capture{ 81 - DistinctId: currentUser.Did, 82 - Event: "star", 83 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 84 - }) 85 - if err != nil { 86 - log.Println("failed to enqueue posthog event:", err) 87 - } 88 - } 89 - 90 return 91 case http.MethodDelete: 92 // find the record in the db ··· 119 return 120 } 121 122 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 123 IsStarred: false, 124 RepoAt: subjectUri, 125 Stats: db.RepoStats{ 126 StarCount: starCount, 127 }, 128 }) 129 - 130 - if !s.config.Core.Dev { 131 - err = s.posthog.Enqueue(posthog.Capture{ 132 - DistinctId: currentUser.Did, 133 - Event: "unstar", 134 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 135 - }) 136 - if err != nil { 137 - log.Println("failed to enqueue posthog event:", err) 138 - } 139 - } 140 141 return 142 }
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 + "tangled.sh/tangled.sh/core/tid" 15 ) 16 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 38 switch r.Method { 39 case http.MethodPost: 40 createdAt := time.Now().Format(time.RFC3339) 41 + rkey := tid.TID() 42 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 Collection: tangled.FeedStarNSID, 44 Repo: currentUser.Did, ··· 53 log.Println("failed to create atproto record", err) 54 return 55 } 56 + log.Println("created atproto record: ", resp.Uri) 57 58 + star := &db.Star{ 59 + StarredByDid: currentUser.Did, 60 + RepoAt: subjectUri, 61 + Rkey: rkey, 62 + } 63 + 64 + err = db.AddStar(s.db, star) 65 if err != nil { 66 log.Println("failed to star", err) 67 return ··· 72 log.Println("failed to get star count for ", subjectUri) 73 } 74 75 + s.notifier.NewStar(r.Context(), star) 76 77 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 IsStarred: true, 79 RepoAt: subjectUri, 80 Stats: db.RepoStats{ ··· 82 }, 83 }) 84 85 return 86 case http.MethodDelete: 87 // find the record in the db ··· 114 return 115 } 116 117 + s.notifier.DeleteStar(r.Context(), star) 118 + 119 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 IsStarred: false, 121 RepoAt: subjectUri, 122 Stats: db.RepoStats{ 123 StarCount: starCount, 124 }, 125 }) 126 127 return 128 }
+27 -353
appview/state/state.go
··· 2 3 import ( 4 "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 "fmt" 9 "log" 10 "log/slog" ··· 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 - "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "github.com/go-chi/chi/v5" ··· 24 "tangled.sh/tangled.sh/core/appview/cache/session" 25 "tangled.sh/tangled.sh/core/appview/config" 26 "tangled.sh/tangled.sh/core/appview/db" 27 - "tangled.sh/tangled.sh/core/appview/idresolver" 28 "tangled.sh/tangled.sh/core/appview/oauth" 29 "tangled.sh/tangled.sh/core/appview/pages" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 "tangled.sh/tangled.sh/core/eventconsumer" 32 "tangled.sh/tangled.sh/core/jetstream" 33 "tangled.sh/tangled.sh/core/knotclient" 34 tlog "tangled.sh/tangled.sh/core/log" 35 "tangled.sh/tangled.sh/core/rbac" 36 ) 37 38 type State struct { 39 db *db.DB 40 oauth *oauth.OAuth 41 enforcer *rbac.Enforcer 42 - tidClock syntax.TIDClock 43 pages *pages.Pages 44 sess *session.SessionStore 45 idResolver *idresolver.Resolver ··· 62 return nil, fmt.Errorf("failed to create enforcer: %w", err) 63 } 64 65 - clock := syntax.NewTIDClock(0) 66 - 67 pgs := pages.NewPages(config) 68 69 - res, err := idresolver.RedisResolver(config.Redis) 70 if err != nil { 71 log.Printf("failed to create redis resolver: %v", err) 72 res = idresolver.DefaultResolver() ··· 134 } 135 spindlestream.Start(ctx) 136 137 state := &State{ 138 d, 139 oauth, 140 enforcer, 141 - clock, 142 pgs, 143 sess, 144 res, ··· 153 return state, nil 154 } 155 156 - func TID(c *syntax.TIDClock) string { 157 - return c.Next().String() 158 } 159 160 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 201 return 202 } 203 204 - // requires auth 205 - func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 206 - switch r.Method { 207 - case http.MethodGet: 208 - // list open registrations under this did 209 - 210 - return 211 - case http.MethodPost: 212 - session, err := s.oauth.Stores().Get(r, oauth.SessionName) 213 - if err != nil || session.IsNew { 214 - log.Println("unauthorized attempt to generate registration key") 215 - http.Error(w, "Forbidden", http.StatusUnauthorized) 216 - return 217 - } 218 - 219 - did := session.Values[oauth.SessionDid].(string) 220 - 221 - // check if domain is valid url, and strip extra bits down to just host 222 - domain := r.FormValue("domain") 223 - if domain == "" { 224 - http.Error(w, "Invalid form", http.StatusBadRequest) 225 - return 226 - } 227 - 228 - key, err := db.GenerateRegistrationKey(s.db, domain, did) 229 - 230 - if err != nil { 231 - log.Println(err) 232 - http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 233 - return 234 - } 235 - 236 - w.Write([]byte(key)) 237 - } 238 - } 239 - 240 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 241 user := chi.URLParam(r, "user") 242 user = strings.TrimPrefix(user, "@") ··· 269 } 270 } 271 272 - // create a signed request and check if a node responds to that 273 - func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 274 - user := s.oauth.GetUser(r) 275 - 276 - domain := chi.URLParam(r, "domain") 277 - if domain == "" { 278 - http.Error(w, "malformed url", http.StatusBadRequest) 279 - return 280 - } 281 - log.Println("checking ", domain) 282 - 283 - secret, err := db.GetRegistrationKey(s.db, domain) 284 - if err != nil { 285 - log.Printf("no key found for domain %s: %s\n", domain, err) 286 - return 287 - } 288 - 289 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 290 - if err != nil { 291 - log.Println("failed to create client to ", domain) 292 - } 293 - 294 - resp, err := client.Init(user.Did) 295 - if err != nil { 296 - w.Write([]byte("no dice")) 297 - log.Println("domain was unreachable after 5 seconds") 298 - return 299 - } 300 - 301 - if resp.StatusCode == http.StatusConflict { 302 - log.Println("status conflict", resp.StatusCode) 303 - w.Write([]byte("already registered, sorry!")) 304 - return 305 - } 306 - 307 - if resp.StatusCode != http.StatusNoContent { 308 - log.Println("status nok", resp.StatusCode) 309 - w.Write([]byte("no dice")) 310 - return 311 - } 312 - 313 - // verify response mac 314 - signature := resp.Header.Get("X-Signature") 315 - signatureBytes, err := hex.DecodeString(signature) 316 - if err != nil { 317 - return 318 - } 319 - 320 - expectedMac := hmac.New(sha256.New, []byte(secret)) 321 - expectedMac.Write([]byte("ok")) 322 - 323 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 324 - log.Printf("response body signature mismatch: %x\n", signatureBytes) 325 - return 326 - } 327 - 328 - tx, err := s.db.BeginTx(r.Context(), nil) 329 - if err != nil { 330 - log.Println("failed to start tx", err) 331 - http.Error(w, err.Error(), http.StatusInternalServerError) 332 - return 333 - } 334 - defer func() { 335 - tx.Rollback() 336 - err = s.enforcer.E.LoadPolicy() 337 - if err != nil { 338 - log.Println("failed to rollback policies") 339 - } 340 - }() 341 - 342 - // mark as registered 343 - err = db.Register(tx, domain) 344 - if err != nil { 345 - log.Println("failed to register domain", err) 346 - http.Error(w, err.Error(), http.StatusInternalServerError) 347 - return 348 - } 349 - 350 - // set permissions for this did as owner 351 - reg, err := db.RegistrationByDomain(tx, domain) 352 - if err != nil { 353 - log.Println("failed to register domain", err) 354 - http.Error(w, err.Error(), http.StatusInternalServerError) 355 - return 356 - } 357 - 358 - // add basic acls for this domain 359 - err = s.enforcer.AddKnot(domain) 360 - if err != nil { 361 - log.Println("failed to setup owner of domain", err) 362 - http.Error(w, err.Error(), http.StatusInternalServerError) 363 - return 364 - } 365 - 366 - // add this did as owner of this domain 367 - err = s.enforcer.AddKnotOwner(domain, reg.ByDid) 368 - if err != nil { 369 - log.Println("failed to setup owner of domain", err) 370 - http.Error(w, err.Error(), http.StatusInternalServerError) 371 - return 372 - } 373 - 374 - err = tx.Commit() 375 - if err != nil { 376 - log.Println("failed to commit changes", err) 377 - http.Error(w, err.Error(), http.StatusInternalServerError) 378 - return 379 - } 380 - 381 - err = s.enforcer.E.SavePolicy() 382 - if err != nil { 383 - log.Println("failed to update ACLs", err) 384 - http.Error(w, err.Error(), http.StatusInternalServerError) 385 - return 386 - } 387 - 388 - // add this knot to knotstream 389 - go s.knotstream.AddSource( 390 - context.Background(), 391 - eventconsumer.NewKnotSource(domain), 392 - ) 393 - 394 - w.Write([]byte("check success")) 395 - } 396 - 397 - func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 398 - domain := chi.URLParam(r, "domain") 399 - if domain == "" { 400 - http.Error(w, "malformed url", http.StatusBadRequest) 401 - return 402 - } 403 - 404 - user := s.oauth.GetUser(r) 405 - reg, err := db.RegistrationByDomain(s.db, domain) 406 - if err != nil { 407 - w.Write([]byte("failed to pull up registration info")) 408 - return 409 - } 410 - 411 - var members []string 412 - if reg.Registered != nil { 413 - members, err = s.enforcer.GetUserByRole("server:member", domain) 414 - if err != nil { 415 - w.Write([]byte("failed to fetch member list")) 416 - return 417 - } 418 - } 419 - 420 - var didsToResolve []string 421 - for _, m := range members { 422 - didsToResolve = append(didsToResolve, m) 423 - } 424 - didsToResolve = append(didsToResolve, reg.ByDid) 425 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 426 - didHandleMap := make(map[string]string) 427 - for _, identity := range resolvedIds { 428 - if !identity.Handle.IsInvalidHandle() { 429 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 430 - } else { 431 - didHandleMap[identity.DID.String()] = identity.DID.String() 432 - } 433 - } 434 - 435 - ok, err := s.enforcer.IsKnotOwner(user.Did, domain) 436 - isOwner := err == nil && ok 437 - 438 - p := pages.KnotParams{ 439 - LoggedInUser: user, 440 - DidHandleMap: didHandleMap, 441 - Registration: reg, 442 - Members: members, 443 - IsOwner: isOwner, 444 - } 445 - 446 - s.pages.Knot(w, p) 447 - } 448 - 449 - // get knots registered by this user 450 - func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 451 - // for now, this is just pubkeys 452 - user := s.oauth.GetUser(r) 453 - registrations, err := db.RegistrationsByDid(s.db, user.Did) 454 - if err != nil { 455 - log.Println(err) 456 - } 457 - 458 - s.pages.Knots(w, pages.KnotsParams{ 459 - LoggedInUser: user, 460 - Registrations: registrations, 461 - }) 462 - } 463 - 464 - // list members of domain, requires auth and requires owner status 465 - func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 466 - domain := chi.URLParam(r, "domain") 467 - if domain == "" { 468 - http.Error(w, "malformed url", http.StatusBadRequest) 469 - return 470 - } 471 - 472 - // list all members for this domain 473 - memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 474 - if err != nil { 475 - w.Write([]byte("failed to fetch member list")) 476 - return 477 - } 478 - 479 - w.Write([]byte(strings.Join(memberDids, "\n"))) 480 - return 481 - } 482 - 483 - // add member to domain, requires auth and requires invite access 484 - func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 485 - domain := chi.URLParam(r, "domain") 486 - if domain == "" { 487 - http.Error(w, "malformed url", http.StatusBadRequest) 488 - return 489 - } 490 - 491 - subjectIdentifier := r.FormValue("subject") 492 - if subjectIdentifier == "" { 493 - http.Error(w, "malformed form", http.StatusBadRequest) 494 - return 495 - } 496 - 497 - subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 498 - if err != nil { 499 - w.Write([]byte("failed to resolve member did to a handle")) 500 - return 501 - } 502 - log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 503 - 504 - // announce this relation into the firehose, store into owners' pds 505 - client, err := s.oauth.AuthorizedClient(r) 506 - if err != nil { 507 - http.Error(w, "failed to authorize client", http.StatusInternalServerError) 508 - return 509 - } 510 - currentUser := s.oauth.GetUser(r) 511 - createdAt := time.Now().Format(time.RFC3339) 512 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 513 - Collection: tangled.KnotMemberNSID, 514 - Repo: currentUser.Did, 515 - Rkey: appview.TID(), 516 - Record: &lexutil.LexiconTypeDecoder{ 517 - Val: &tangled.KnotMember{ 518 - Subject: subjectIdentity.DID.String(), 519 - Domain: domain, 520 - CreatedAt: createdAt, 521 - }}, 522 - }) 523 - 524 - // invalid record 525 - if err != nil { 526 - log.Printf("failed to create record: %s", err) 527 - return 528 - } 529 - log.Println("created atproto record: ", resp.Uri) 530 - 531 - secret, err := db.GetRegistrationKey(s.db, domain) 532 - if err != nil { 533 - log.Printf("no key found for domain %s: %s\n", domain, err) 534 - return 535 - } 536 - 537 - ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 538 - if err != nil { 539 - log.Println("failed to create client to ", domain) 540 - return 541 - } 542 - 543 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 544 - if err != nil { 545 - log.Printf("failed to make request to %s: %s", domain, err) 546 - return 547 - } 548 - 549 - if ksResp.StatusCode != http.StatusNoContent { 550 - w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 551 - return 552 - } 553 - 554 - err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 555 - if err != nil { 556 - w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 557 - return 558 - } 559 - 560 - w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 561 - } 562 - 563 - func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 564 - } 565 - 566 func validateRepoName(name string) error { 567 // check for path traversal attempts 568 if name == "." || name == ".." || ··· 661 return 662 } 663 664 - rkey := appview.TID() 665 repo := &db.Repo{ 666 Did: user.Did, 667 Name: repoName, ··· 757 return 758 } 759 760 - if !s.config.Core.Dev { 761 - err = s.posthog.Enqueue(posthog.Capture{ 762 - DistinctId: user.Did, 763 - Event: "new_repo", 764 - Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri}, 765 - }) 766 - if err != nil { 767 - log.Println("failed to enqueue posthog event:", err) 768 - } 769 - } 770 771 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 772 return
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "log/slog" ··· 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/go-chi/chi/v5" ··· 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 + "tangled.sh/tangled.sh/core/idresolver" 30 "tangled.sh/tangled.sh/core/jetstream" 31 "tangled.sh/tangled.sh/core/knotclient" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 35 ) 36 37 type State struct { 38 db *db.DB 39 + notifier notify.Notifier 40 oauth *oauth.OAuth 41 enforcer *rbac.Enforcer 42 pages *pages.Pages 43 sess *session.SessionStore 44 idResolver *idresolver.Resolver ··· 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 } 63 64 pgs := pages.NewPages(config) 65 66 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 if err != nil { 68 log.Printf("failed to create redis resolver: %v", err) 69 res = idresolver.DefaultResolver() ··· 131 } 132 spindlestream.Start(ctx) 133 134 + var notifiers []notify.Notifier 135 + if !config.Core.Dev { 136 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 137 + } 138 + notifier := notify.NewMergedNotifier(notifiers...) 139 + 140 state := &State{ 141 d, 142 + notifier, 143 oauth, 144 enforcer, 145 pgs, 146 sess, 147 res, ··· 156 return state, nil 157 } 158 159 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 160 + user := s.oauth.GetUser(r) 161 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 162 + LoggedInUser: user, 163 + }) 164 + } 165 + 166 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 167 + user := s.oauth.GetUser(r) 168 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 169 + LoggedInUser: user, 170 + }) 171 } 172 173 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 214 return 215 } 216 217 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 218 user := chi.URLParam(r, "user") 219 user = strings.TrimPrefix(user, "@") ··· 246 } 247 } 248 249 func validateRepoName(name string) error { 250 // check for path traversal attempts 251 if name == "." || name == ".." || ··· 344 return 345 } 346 347 + rkey := tid.TID() 348 repo := &db.Repo{ 349 Did: user.Did, 350 Name: repoName, ··· 440 return 441 } 442 443 + s.notifier.NewRepo(r.Context(), repo) 444 445 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 446 return
+14 -6
appview/state/userutil/userutil.go
··· 5 "strings" 6 ) 7 8 func IsHandleNoAt(s string) bool { 9 // ref: https://atproto.com/specs/handle 10 - re := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 11 - return re.MatchString(s) 12 } 13 14 func UnflattenDid(s string) string { ··· 29 // Reconstruct as a standard DID format using Replace 30 // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" 31 reconstructed := strings.Replace(s, "-", ":", 2) 32 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 33 34 - return re.MatchString(reconstructed) 35 } 36 37 // FlattenDid converts a DID to a flattened format. ··· 46 47 // IsDid checks if the given string is a standard DID. 48 func IsDid(s string) bool { 49 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 50 - return re.MatchString(s) 51 }
··· 5 "strings" 6 ) 7 8 + var ( 9 + handleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 10 + didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 + ) 12 + 13 func IsHandleNoAt(s string) bool { 14 // ref: https://atproto.com/specs/handle 15 + return handleRegex.MatchString(s) 16 } 17 18 func UnflattenDid(s string) string { ··· 33 // Reconstruct as a standard DID format using Replace 34 // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" 35 reconstructed := strings.Replace(s, "-", ":", 2) 36 37 + return didRegex.MatchString(reconstructed) 38 } 39 40 // FlattenDid converts a DID to a flattened format. ··· 49 50 // IsDid checks if the given string is a standard DID. 51 func IsDid(s string) bool { 52 + return didRegex.MatchString(s) 53 + } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 }
-11
appview/tid.go
··· 1 - package appview 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/syntax" 5 - ) 6 - 7 - var c syntax.TIDClock = syntax.NewTIDClock(0) 8 - 9 - func TID() string { 10 - return c.Next().String() 11 - }
···
+15
appview/xrpcclient/xrpc.go
··· 87 88 return &out, nil 89 }
··· 87 88 return &out, nil 89 } 90 + 91 + func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 92 + var out atproto.ServerGetServiceAuth_Output 93 + 94 + params := map[string]interface{}{ 95 + "aud": aud, 96 + "exp": exp, 97 + "lxm": lxm, 98 + } 99 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 100 + return nil, err 101 + } 102 + 103 + return &out, nil 104 + }
+33 -4
avatar/src/index.js
··· 1 export default { 2 async fetch(request, env) { 3 const url = new URL(request.url); 4 const { pathname, searchParams } = url; 5 ··· 60 const profile = await profileResponse.json(); 61 const avatar = profile.avatar; 62 63 - if (!avatar) { 64 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 65 } 66 67 // Resize if requested 68 let avatarResponse; 69 if (resizeToTiny) { 70 - avatarResponse = await fetch(avatar, { 71 cf: { 72 image: { 73 width: 32, ··· 78 }, 79 }); 80 } else { 81 - avatarResponse = await fetch(avatar); 82 } 83 84 if (!avatarResponse.ok) {
··· 1 export default { 2 async fetch(request, env) { 3 + // Helper function to generate a color from a string 4 + const stringToColor = (str) => { 5 + let hash = 0; 6 + for (let i = 0; i < str.length; i++) { 7 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 8 + } 9 + let color = "#"; 10 + for (let i = 0; i < 3; i++) { 11 + const value = (hash >> (i * 8)) & 0xff; 12 + color += ("00" + value.toString(16)).substr(-2); 13 + } 14 + return color; 15 + }; 16 + 17 const url = new URL(request.url); 18 const { pathname, searchParams } = url; 19 ··· 74 const profile = await profileResponse.json(); 75 const avatar = profile.avatar; 76 77 + let avatarUrl = profile.avatar; 78 + 79 + if (!avatarUrl) { 80 + // Generate a random color based on the actor string 81 + const bgColor = stringToColor(actor); 82 + const size = resizeToTiny ? 32 : 128; 83 + const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`; 84 + const svgData = new TextEncoder().encode(svg); 85 + 86 + response = new Response(svgData, { 87 + headers: { 88 + "Content-Type": "image/svg+xml", 89 + "Cache-Control": "public, max-age=43200", 90 + }, 91 + }); 92 + await cache.put(cacheKey, response.clone()); 93 + return response; 94 } 95 96 // Resize if requested 97 let avatarResponse; 98 if (resizeToTiny) { 99 + avatarResponse = await fetch(avatarUrl, { 100 cf: { 101 image: { 102 width: 32, ··· 107 }, 108 }); 109 } else { 110 + avatarResponse = await fetch(avatarUrl); 111 } 112 113 if (!avatarResponse.ok) {
+4
cmd/gen.go
··· 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 tangled.ActorProfile{}, 18 tangled.FeedStar{}, 19 tangled.GitRefUpdate{}, 20 tangled.GitRefUpdate_Meta{}, 21 tangled.GitRefUpdate_Meta_CommitCount{}, 22 tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 23 tangled.GraphFollow{}, 24 tangled.KnotMember{}, 25 tangled.Pipeline{}, ··· 37 tangled.PublicKey{}, 38 tangled.Repo{}, 39 tangled.RepoArtifact{}, 40 tangled.RepoIssue{}, 41 tangled.RepoIssueComment{}, 42 tangled.RepoIssueState{},
··· 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{}, 21 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 tangled.GraphFollow{}, 27 tangled.KnotMember{}, 28 tangled.Pipeline{}, ··· 40 tangled.PublicKey{}, 41 tangled.Repo{}, 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 44 tangled.RepoIssue{}, 45 tangled.RepoIssueComment{}, 46 tangled.RepoIssueState{},
+46 -3
docs/hacking.md
··· 32 nix run .#watch-tailwind 33 ``` 34 35 ## running a knot 36 37 An end-to-end knot setup requires setting up a machine with ··· 39 quite cumbersome. So the nix flake provides a 40 `nixosConfiguration` to do so. 41 42 - To begin, head to `http://localhost:3000` in the browser and 43 - generate a knot secret. Replace the existing secret in 44 - `flake.nix` with the newly generated secret. 45 46 You can now start a lightweight NixOS VM using 47 `nixos-shell` like so: ··· 71 git remote add local-dev git@nixos-shell:user/repo 72 git push local-dev main 73 ```
··· 32 nix run .#watch-tailwind 33 ``` 34 35 + To authenticate with the appview, you will need redis and 36 + OAUTH JWKs to be setup: 37 + 38 + ``` 39 + # oauth jwks should already be setup by the nix devshell: 40 + echo $TANGLED_OAUTH_JWKS 41 + {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 42 + 43 + # if not, you can set it up yourself: 44 + go build -o genjwks.out ./cmd/genjwks 45 + export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 46 + 47 + # run redis in at a new shell to store oauth sessions 48 + redis-server 49 + ``` 50 + 51 ## running a knot 52 53 An end-to-end knot setup requires setting up a machine with ··· 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 + To begin, head to `http://localhost:3000/knots` in the browser 59 + and generate a knot secret. Replace the existing secret in 60 + `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 + secret. 62 63 You can now start a lightweight NixOS VM using 64 `nixos-shell` like so: ··· 88 git remote add local-dev git@nixos-shell:user/repo 89 git push local-dev main 90 ``` 91 + 92 + ## running a spindle 93 + 94 + Be sure to change the `owner` field for the spindle in 95 + `nix/vm.nix` to your own DID. The above VM should already 96 + be running a spindle on `localhost:6555`. You can head to 97 + the spindle dashboard on `http://localhost:3000/spindles`, 98 + and register a spindle with hostname `localhost:6555`. It 99 + should instantly be verified. You can then configure each 100 + repository to use this spindle and run CI jobs. 101 + 102 + Of interest when debugging spindles: 103 + 104 + ``` 105 + # service logs from journald: 106 + journalctl -xeu spindle 107 + 108 + # CI job logs from disk: 109 + ls /var/log/spindle 110 + 111 + # debugging spindle db: 112 + sqlite3 /var/lib/spindle/spindle.db 113 + 114 + # litecli has a nicer REPL interface: 115 + litecli /var/lib/spindle/spindle.db 116 + ```
+12
docs/knot-hosting.md
··· 191 ``` 192 193 Make sure to restart your SSH server!
··· 191 ``` 192 193 Make sure to restart your SSH server! 194 + 195 + #### MOTD (message of the day) 196 + 197 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 198 + `/home/git/motd` file: 199 + 200 + ``` 201 + printf "Hi from this knot!\n" > /home/git/motd 202 + ``` 203 + 204 + Note that you should add a newline at the end if setting a non-empty message 205 + since the knot won't do this for you.
+4 -3
docs/spindle/architecture.md
··· 13 14 ### the engine 15 16 - At present, the only supported backend is Docker. Spindle executes each step in 17 - the pipeline in a fresh container, with state persisted across steps within the 18 - `/tangled/workspace` directory. 19 20 The base image for the container is constructed on the fly using 21 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
··· 13 14 ### the engine 15 16 + At present, the only supported backend is Docker (and Podman, if Docker 17 + compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 + executes each step in the pipeline in a fresh container, with state persisted 19 + across steps within the `/tangled/workspace` directory. 20 21 The base image for the container is constructed on the fly using 22 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
+8 -1
docs/spindle/hosting.md
··· 36 go build -o cmd/spindle/spindle cmd/spindle/main.go 37 ``` 38 39 - 3. **Run the Spindle binary.** 40 41 ```shell 42 ./cmd/spindle/spindle
··· 36 go build -o cmd/spindle/spindle cmd/spindle/main.go 37 ``` 38 39 + 3. **Create the log directory.** 40 + 41 + ```shell 42 + sudo mkdir -p /var/log/spindle 43 + sudo chown $USER:$USER -R /var/log/spindle 44 + ``` 45 + 46 + 4. **Run the Spindle binary.** 47 48 ```shell 49 ./cmd/spindle/spindle
+285
docs/spindle/openbao.md
···
··· 1 + # spindle secrets with openbao 2 + 3 + This document covers setting up Spindle to use OpenBao for secrets 4 + management via OpenBao Proxy instead of the default SQLite backend. 5 + 6 + ## overview 7 + 8 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 + authentication automatically using AppRole credentials, while Spindle 10 + connects to the local proxy instead of directly to the OpenBao server. 11 + 12 + This approach provides better security, automatic token renewal, and 13 + simplified application code. 14 + 15 + ## installation 16 + 17 + Install OpenBao from nixpkgs: 18 + 19 + ```bash 20 + nix shell nixpkgs#openbao # for a local server 21 + ``` 22 + 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 28 + 29 + Start OpenBao in dev mode: 30 + 31 + ```bash 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 + ``` 34 + 35 + This starts OpenBao on `http://localhost:8201` with a root token. 36 + 37 + Set up environment for bao CLI: 38 + 39 + ```bash 40 + export BAO_ADDR=http://localhost:8200 41 + export BAO_TOKEN=root 42 + ``` 43 + 44 + ### production 45 + 46 + You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 + achieved using Nix. 49 + 50 + Then, initialize the bao server: 51 + ```bash 52 + bao operator init -key-shares=1 -key-threshold=1 53 + ``` 54 + 55 + This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 + ```bash 57 + bao operator unseal <unseal_key> 58 + ``` 59 + 60 + All steps below remain the same across both dev and production setups. 61 + 62 + ### configure openbao server 63 + 64 + Create the spindle KV mount: 65 + 66 + ```bash 67 + bao secrets enable -path=spindle -version=2 kv 68 + ``` 69 + 70 + Set up AppRole authentication and policy: 71 + 72 + Create a policy file `spindle-policy.hcl`: 73 + 74 + ```hcl 75 + # Full access to spindle KV v2 data 76 + path "spindle/data/*" { 77 + capabilities = ["create", "read", "update", "delete"] 78 + } 79 + 80 + # Access to metadata for listing and management 81 + path "spindle/metadata/*" { 82 + capabilities = ["list", "read", "delete", "update"] 83 + } 84 + 85 + # Allow listing at root level 86 + path "spindle/" { 87 + capabilities = ["list"] 88 + } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 94 + ``` 95 + 96 + Apply the policy and create an AppRole: 97 + 98 + ```bash 99 + bao policy write spindle-policy spindle-policy.hcl 100 + bao auth enable approle 101 + bao write auth/approle/role/spindle \ 102 + token_policies="spindle-policy" \ 103 + token_ttl=1h \ 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 108 + ``` 109 + 110 + Get the credentials: 111 + 112 + ```bash 113 + # Get role ID (static) 114 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 + 116 + # Generate secret ID 117 + SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id) 118 + 119 + echo "Role ID: $ROLE_ID" 120 + echo "Secret ID: $SECRET_ID" 121 + ``` 122 + 123 + ### create proxy configuration 124 + 125 + Create the credential files: 126 + 127 + ```bash 128 + # Create directory for OpenBao files 129 + mkdir -p /tmp/openbao 130 + 131 + # Save credentials 132 + echo "$ROLE_ID" > /tmp/openbao/role-id 133 + echo "$SECRET_ID" > /tmp/openbao/secret-id 134 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 + ``` 136 + 137 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 + 139 + ```hcl 140 + # OpenBao server connection 141 + vault { 142 + address = "http://localhost:8200" 143 + } 144 + 145 + # Auto-Auth using AppRole 146 + auto_auth { 147 + method "approle" { 148 + mount_path = "auth/approle" 149 + config = { 150 + role_id_file_path = "/tmp/openbao/role-id" 151 + secret_id_file_path = "/tmp/openbao/secret-id" 152 + } 153 + } 154 + 155 + # Optional: write token to file for debugging 156 + sink "file" { 157 + config = { 158 + path = "/tmp/openbao/token" 159 + mode = 0640 160 + } 161 + } 162 + } 163 + 164 + # Proxy listener for Spindle 165 + listener "tcp" { 166 + address = "127.0.0.1:8201" 167 + tls_disable = true 168 + } 169 + 170 + # Enable API proxy with auto-auth token 171 + api_proxy { 172 + use_auto_auth_token = true 173 + } 174 + 175 + # Enable response caching 176 + cache { 177 + use_auto_auth_token = true 178 + } 179 + 180 + # Logging 181 + log_level = "info" 182 + ``` 183 + 184 + ### start the proxy 185 + 186 + Start OpenBao Proxy: 187 + 188 + ```bash 189 + bao proxy -config=/tmp/openbao/proxy.hcl 190 + ``` 191 + 192 + The proxy will authenticate with OpenBao and start listening on 193 + `127.0.0.1:8201`. 194 + 195 + ### configure spindle 196 + 197 + Set these environment variables for Spindle: 198 + 199 + ```bash 200 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 + ``` 204 + 205 + Start Spindle: 206 + 207 + Spindle will now connect to the local proxy, which handles all 208 + authentication automatically. 209 + 210 + ## production setup for proxy 211 + 212 + For production, you'll want to run the proxy as a service: 213 + 214 + Place your production configuration in `/etc/openbao/proxy.hcl` with 215 + proper TLS settings for the vault connection. 216 + 217 + ## verifying setup 218 + 219 + Test the proxy directly: 220 + 221 + ```bash 222 + # Check proxy health 223 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 + 225 + # Test token lookup through proxy 226 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 + ``` 228 + 229 + Test OpenBao operations through the server: 230 + 231 + ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 236 + bao kv list spindle/repos/ 237 + 238 + # Get a specific secret 239 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 + ``` 241 + 242 + ## how it works 243 + 244 + - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 + - The proxy authenticates with OpenBao using AppRole credentials 246 + - All Spindle requests go through the proxy, which injects authentication tokens 247 + - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 + - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 + - The proxy handles all token renewal automatically 250 + - Spindle no longer manages tokens or authentication directly 251 + 252 + ## troubleshooting 253 + 254 + **Connection refused**: Check that the OpenBao Proxy is running and 255 + listening on the configured address. 256 + 257 + **403 errors**: Verify the AppRole credentials are correct and the policy 258 + has the necessary permissions. 259 + 260 + **404 route errors**: The spindle KV mount probably doesn't exist - run 261 + the mount creation step again. 262 + 263 + **Proxy authentication failures**: Check the proxy logs and verify the 264 + role-id and secret-id files are readable and contain valid credentials. 265 + 266 + **Secret not found after writing**: This can indicate policy permission 267 + issues. Verify the policy includes both `spindle/data/*` and 268 + `spindle/metadata/*` paths with appropriate capabilities. 269 + 270 + Check proxy logs: 271 + 272 + ```bash 273 + # If running as systemd service 274 + journalctl -u openbao-proxy -f 275 + 276 + # If running directly, check the console output 277 + ``` 278 + 279 + Test AppRole authentication manually: 280 + 281 + ```bash 282 + bao write auth/approle/login \ 283 + role_id="$(cat /tmp/openbao/role-id)" \ 284 + secret_id="$(cat /tmp/openbao/secret-id)" 285 + ```
+7
docs/spindle/pipeline.md
··· 57 depth: 50 58 submodules: true 59 ```
··· 57 depth: 50 58 submodules: true 59 ``` 60 + 61 + ## git push options 62 + 63 + These are push options that can be used with the `--push-option (-o)` flag of git push: 64 + 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.
+58 -3
flake.lock
··· 20 "type": "github" 21 } 22 }, 23 "htmx-src": { 24 "flake": false, 25 "locked": { ··· 101 }, 102 "nixpkgs": { 103 "locked": { 104 - "lastModified": 1746904237, 105 - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", 106 "owner": "nixos", 107 "repo": "nixpkgs", 108 - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", 109 "type": "github" 110 }, 111 "original": { ··· 118 "root": { 119 "inputs": { 120 "gitignore": "gitignore", 121 "htmx-src": "htmx-src", 122 "htmx-ws-src": "htmx-ws-src", 123 "ibm-plex-mono-src": "ibm-plex-mono-src", ··· 139 "original": { 140 "type": "tarball", 141 "url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip" 142 } 143 } 144 },
··· 20 "type": "github" 21 } 22 }, 23 + "flake-utils": { 24 + "inputs": { 25 + "systems": "systems" 26 + }, 27 + "locked": { 28 + "lastModified": 1694529238, 29 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 30 + "owner": "numtide", 31 + "repo": "flake-utils", 32 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 33 + "type": "github" 34 + }, 35 + "original": { 36 + "owner": "numtide", 37 + "repo": "flake-utils", 38 + "type": "github" 39 + } 40 + }, 41 + "gomod2nix": { 42 + "inputs": { 43 + "flake-utils": "flake-utils", 44 + "nixpkgs": [ 45 + "nixpkgs" 46 + ] 47 + }, 48 + "locked": { 49 + "lastModified": 1751702058, 50 + "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 51 + "owner": "nix-community", 52 + "repo": "gomod2nix", 53 + "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 54 + "type": "github" 55 + }, 56 + "original": { 57 + "owner": "nix-community", 58 + "repo": "gomod2nix", 59 + "type": "github" 60 + } 61 + }, 62 "htmx-src": { 63 "flake": false, 64 "locked": { ··· 140 }, 141 "nixpkgs": { 142 "locked": { 143 + "lastModified": 1751984180, 144 + "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 145 "owner": "nixos", 146 "repo": "nixpkgs", 147 + "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 148 "type": "github" 149 }, 150 "original": { ··· 157 "root": { 158 "inputs": { 159 "gitignore": "gitignore", 160 + "gomod2nix": "gomod2nix", 161 "htmx-src": "htmx-src", 162 "htmx-ws-src": "htmx-ws-src", 163 "ibm-plex-mono-src": "ibm-plex-mono-src", ··· 179 "original": { 180 "type": "tarball", 181 "url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip" 182 + } 183 + }, 184 + "systems": { 185 + "locked": { 186 + "lastModified": 1681028828, 187 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 188 + "owner": "nix-systems", 189 + "repo": "default", 190 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 191 + "type": "github" 192 + }, 193 + "original": { 194 + "owner": "nix-systems", 195 + "repo": "default", 196 + "type": "github" 197 } 198 } 199 },
+65 -23
flake.nix
··· 3 4 inputs = { 5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 indigo = { 7 url = "github:oppiliappan/indigo"; 8 flake = false; ··· 42 outputs = { 43 self, 44 nixpkgs, 45 indigo, 46 htmx-src, 47 htmx-ws-src, ··· 54 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 56 nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 57 - inherit (gitignore.lib) gitignoreSource; 58 - mkPackageSet = pkgs: let 59 - goModHash = "sha256-SLi+nALwCd/Lzn3aljwPqCo2UaM9hl/4OAjcHQLt2Bk="; 60 - sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix { 61 - inherit (pkgs) gcc; 62 - inherit sqlite-lib-src; 63 - }; 64 - genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;}; 65 - lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 66 - appview = pkgs.callPackage ./nix/pkgs/appview.nix { 67 - inherit sqlite-lib htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource; 68 - }; 69 - spindle = pkgs.callPackage ./nix/pkgs/spindle.nix {inherit sqlite-lib goModHash gitignoreSource;}; 70 - knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix {inherit sqlite-lib goModHash gitignoreSource;}; 71 - knot = pkgs.callPackage ./nix/pkgs/knot.nix {inherit knot-unwrapped;}; 72 - in { 73 - inherit lexgen appview spindle knot-unwrapped knot sqlite-lib genjwks; 74 - }; 75 in { 76 - overlays.default = final: prev: mkPackageSet final; 77 78 packages = forAllSystems (system: let 79 pkgs = nixpkgsFor.${system}; ··· 142 '' 143 ${pkgs.air}/bin/air -c /dev/null \ 144 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 145 - -build.bin "./out/${name}.out ${arg}" \ 146 -build.stop_on_error "true" \ 147 -build.include_ext "go" 148 ''; ··· 170 ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 171 ''); 172 }; 173 }); 174 175 - nixosModules.appview = import ./nix/modules/appview.nix {inherit self;}; 176 - nixosModules.knot = import ./nix/modules/knot.nix {inherit self;}; 177 - nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;}; 178 nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 179 }; 180 }
··· 3 4 inputs = { 5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 + gomod2nix = { 7 + url = "github:nix-community/gomod2nix"; 8 + inputs.nixpkgs.follows = "nixpkgs"; 9 + }; 10 indigo = { 11 url = "github:oppiliappan/indigo"; 12 flake = false; ··· 46 outputs = { 47 self, 48 nixpkgs, 49 + gomod2nix, 50 indigo, 51 htmx-src, 52 htmx-ws-src, ··· 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 61 nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 62 + 63 + mkPackageSet = pkgs: 64 + pkgs.lib.makeScope pkgs.newScope (self: { 65 + inherit (gitignore.lib) gitignoreSource; 66 + buildGoApplication = 67 + (self.callPackage "${gomod2nix}/builder" { 68 + gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 69 + }).buildGoApplication; 70 + modules = ./nix/gomod2nix.toml; 71 + sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 72 + inherit (pkgs) gcc; 73 + inherit sqlite-lib-src; 74 + }; 75 + genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 + lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 + appview = self.callPackage ./nix/pkgs/appview.nix { 78 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 + }; 80 + spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 + knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 + knot = self.callPackage ./nix/pkgs/knot.nix {}; 83 + }); 84 in { 85 + overlays.default = final: prev: { 86 + inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 87 + }; 88 89 packages = forAllSystems (system: let 90 pkgs = nixpkgsFor.${system}; ··· 153 '' 154 ${pkgs.air}/bin/air -c /dev/null \ 155 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 156 + -build.bin "./out/${name}.out" \ 157 + -build.args_bin "${arg}" \ 158 -build.stop_on_error "true" \ 159 -build.include_ext "go" 160 ''; ··· 182 ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 183 ''); 184 }; 185 + gomod2nix = { 186 + type = "app"; 187 + program = toString (pkgs.writeShellScript "gomod2nix" '' 188 + ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 + ''); 190 + }; 191 }); 192 193 + nixosModules.appview = { 194 + lib, 195 + pkgs, 196 + ... 197 + }: { 198 + imports = [./nix/modules/appview.nix]; 199 + 200 + services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 201 + }; 202 + nixosModules.knot = { 203 + lib, 204 + pkgs, 205 + ... 206 + }: { 207 + imports = [./nix/modules/knot.nix]; 208 + 209 + services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 210 + }; 211 + nixosModules.spindle = { 212 + lib, 213 + pkgs, 214 + ... 215 + }: { 216 + imports = [./nix/modules/spindle.nix]; 217 + 218 + services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 + }; 220 nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 221 }; 222 }
+54 -34
go.mod
··· 1 module tangled.sh/tangled.sh/core 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 6 7 require ( 8 github.com/Blank-Xu/sql-adapter v1.1.1 9 github.com/alecthomas/chroma/v2 v2.15.0 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 github.com/cyphar/filepath-securejoin v0.4.1 16 github.com/dgraph-io/ristretto v0.2.0 17 github.com/docker/docker v28.2.2+incompatible ··· 22 github.com/go-git/go-git/v5 v5.14.0 23 github.com/google/uuid v1.6.0 24 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 26 github.com/hiddeco/sshsig v0.2.0 27 github.com/hpcloud/tail v1.0.0 28 github.com/ipfs/go-cid v0.5.0 29 github.com/lestrrat-go/jwx/v2 v2.1.6 30 github.com/mattn/go-sqlite3 v1.14.24 31 github.com/microcosm-cc/bluemonday v1.0.27 32 github.com/posthog/posthog-go v1.5.5 33 - github.com/redis/go-redis/v9 v9.3.0 34 github.com/resend/resend-go/v2 v2.15.0 35 github.com/sethvargo/go-envconfig v1.1.0 36 github.com/stretchr/testify v1.10.0 37 github.com/urfave/cli/v3 v3.3.3 38 github.com/whyrusleeping/cbor-gen v0.3.1 39 github.com/yuin/goldmark v1.4.13 40 - golang.org/x/crypto v0.38.0 41 - golang.org/x/net v0.40.0 42 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 43 gopkg.in/yaml.v3 v3.0.1 44 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 45 ) 46 47 require ( 48 dario.cat/mergo v1.0.1 // indirect 49 github.com/Microsoft/go-winio v0.6.2 // indirect 50 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 51 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 52 - github.com/avast/retry-go/v4 v4.6.1 // indirect 53 github.com/aymerick/douceur v0.2.0 // indirect 54 github.com/beorn7/perks v1.0.1 // indirect 55 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 github.com/casbin/govaluate v1.3.0 // indirect 57 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 59 github.com/containerd/errdefs v1.0.0 // indirect 60 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 github.com/containerd/log v0.1.0 // indirect ··· 68 github.com/docker/go-units v0.5.0 // indirect 69 github.com/emirpasic/gods v1.18.1 // indirect 70 github.com/felixge/httpsnoop v1.0.4 // indirect 71 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 github.com/go-git/go-billy/v5 v5.6.2 // indirect 74 - github.com/go-logr/logr v1.4.2 // indirect 75 github.com/go-logr/stdr v1.2.2 // indirect 76 github.com/go-redis/cache/v9 v9.0.0 // indirect 77 github.com/goccy/go-json v0.10.5 // indirect 78 github.com/gogo/protobuf v1.3.2 // indirect 79 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 80 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 81 github.com/gorilla/css v1.0.1 // indirect 82 github.com/gorilla/securecookie v1.1.2 // indirect 83 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 85 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 87 github.com/ipfs/bbloom v0.0.4 // indirect 88 - github.com/ipfs/boxo v0.30.0 // indirect 89 - github.com/ipfs/go-block-format v0.2.1 // indirect 90 github.com/ipfs/go-datastore v0.8.2 // indirect 91 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 92 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 93 - github.com/ipfs/go-ipld-cbor v0.2.0 // indirect 94 - github.com/ipfs/go-ipld-format v0.6.1 // indirect 95 github.com/ipfs/go-log v1.0.5 // indirect 96 github.com/ipfs/go-log/v2 v2.6.0 // indirect 97 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 98 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 github.com/klauspost/compress v1.18.0 // indirect 100 - github.com/klauspost/cpuid/v2 v2.2.10 // indirect 101 - github.com/lestrrat-go/blackmagic v1.0.3 // indirect 102 github.com/lestrrat-go/httpcc v1.0.1 // indirect 103 github.com/lestrrat-go/httprc v1.0.6 // indirect 104 github.com/lestrrat-go/iter v1.0.2 // indirect 105 github.com/lestrrat-go/option v1.0.1 // indirect 106 github.com/mattn/go-isatty v0.0.20 // indirect 107 github.com/minio/sha256-simd v1.0.1 // indirect 108 github.com/moby/docker-image-spec v1.3.1 // indirect 109 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 github.com/moby/term v0.5.2 // indirect ··· 116 github.com/multiformats/go-multihash v0.2.3 // indirect 117 github.com/multiformats/go-varint v0.0.7 // indirect 118 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 119 github.com/opencontainers/go-digest v1.0.0 // indirect 120 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 122 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 github.com/pkg/errors v0.9.1 // indirect 124 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 125 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 126 github.com/prometheus/client_golang v1.22.0 // indirect 127 github.com/prometheus/client_model v0.6.2 // indirect 128 - github.com/prometheus/common v0.63.0 // indirect 129 github.com/prometheus/procfs v0.16.1 // indirect 130 github.com/segmentio/asm v1.2.0 // indirect 131 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 136 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 137 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 138 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 139 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 140 - go.opentelemetry.io/otel v1.36.0 // indirect 141 - go.opentelemetry.io/otel/metric v1.36.0 // indirect 142 - go.opentelemetry.io/otel/trace v1.36.0 // indirect 143 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 144 go.uber.org/atomic v1.11.0 // indirect 145 go.uber.org/multierr v1.11.0 // indirect 146 go.uber.org/zap v1.27.0 // indirect 147 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 148 - golang.org/x/sync v0.14.0 // indirect 149 - golang.org/x/sys v0.33.0 // indirect 150 - golang.org/x/time v0.8.0 // indirect 151 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 152 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 153 - google.golang.org/grpc v1.72.1 // indirect 154 google.golang.org/protobuf v1.36.6 // indirect 155 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
··· 1 module tangled.sh/tangled.sh/core 2 3 + go 1.24.4 4 5 require ( 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 8 github.com/alecthomas/chroma/v2 v2.15.0 9 + github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 16 github.com/cyphar/filepath-securejoin v0.4.1 17 github.com/dgraph-io/ristretto v0.2.0 18 github.com/docker/docker v28.2.2+incompatible ··· 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 github.com/gorilla/sessions v1.4.0 26 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 27 github.com/hiddeco/sshsig v0.2.0 28 github.com/hpcloud/tail v1.0.0 29 github.com/ipfs/go-cid v0.5.0 30 github.com/lestrrat-go/jwx/v2 v2.1.6 31 github.com/mattn/go-sqlite3 v1.14.24 32 github.com/microcosm-cc/bluemonday v1.0.27 33 + github.com/openbao/openbao/api/v2 v2.3.0 34 github.com/posthog/posthog-go v1.5.5 35 + github.com/redis/go-redis/v9 v9.7.3 36 github.com/resend/resend-go/v2 v2.15.0 37 github.com/sethvargo/go-envconfig v1.1.0 38 github.com/stretchr/testify v1.10.0 39 github.com/urfave/cli/v3 v3.3.3 40 github.com/whyrusleeping/cbor-gen v0.3.1 41 github.com/yuin/goldmark v1.4.13 42 + golang.org/x/crypto v0.40.0 43 + golang.org/x/net v0.42.0 44 + golang.org/x/sync v0.16.0 45 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 46 gopkg.in/yaml.v3 v3.0.1 47 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 48 ) 49 50 require ( 51 dario.cat/mergo v1.0.1 // indirect 52 github.com/Microsoft/go-winio v0.6.2 // indirect 53 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 54 + github.com/alecthomas/repr v0.4.0 // indirect 55 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 56 github.com/aymerick/douceur v0.2.0 // indirect 57 github.com/beorn7/perks v1.0.1 // indirect 58 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 59 github.com/casbin/govaluate v1.3.0 // indirect 60 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 61 github.com/cespare/xxhash/v2 v2.3.0 // indirect 62 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 63 github.com/containerd/errdefs v1.0.0 // indirect 64 github.com/containerd/errdefs/pkg v0.3.0 // indirect 65 github.com/containerd/log v0.1.0 // indirect ··· 72 github.com/docker/go-units v0.5.0 // indirect 73 github.com/emirpasic/gods v1.18.1 // indirect 74 github.com/felixge/httpsnoop v1.0.4 // indirect 75 + github.com/fsnotify/fsnotify v1.6.0 // indirect 76 github.com/go-enry/go-oniguruma v1.2.1 // indirect 77 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 78 github.com/go-git/go-billy/v5 v5.6.2 // indirect 79 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 80 + github.com/go-logr/logr v1.4.3 // indirect 81 github.com/go-logr/stdr v1.2.2 // indirect 82 github.com/go-redis/cache/v9 v9.0.0 // indirect 83 + github.com/go-test/deep v1.1.1 // indirect 84 github.com/goccy/go-json v0.10.5 // indirect 85 github.com/gogo/protobuf v1.3.2 // indirect 86 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 87 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 88 + github.com/golang/mock v1.6.0 // indirect 89 + github.com/google/go-querystring v1.1.0 // indirect 90 github.com/gorilla/css v1.0.1 // indirect 91 github.com/gorilla/securecookie v1.1.2 // indirect 92 + github.com/hashicorp/errwrap v1.1.0 // indirect 93 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 94 + github.com/hashicorp/go-multierror v1.1.1 // indirect 95 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 96 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 97 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 98 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 99 github.com/hashicorp/golang-lru v1.0.2 // indirect 100 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 101 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 102 + github.com/hexops/gotextdiff v1.0.3 // indirect 103 github.com/ipfs/bbloom v0.0.4 // indirect 104 + github.com/ipfs/boxo v0.33.0 // indirect 105 + github.com/ipfs/go-block-format v0.2.2 // indirect 106 github.com/ipfs/go-datastore v0.8.2 // indirect 107 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 108 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 109 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 110 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 111 github.com/ipfs/go-log v1.0.5 // indirect 112 github.com/ipfs/go-log/v2 v2.6.0 // indirect 113 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 114 github.com/kevinburke/ssh_config v1.2.0 // indirect 115 github.com/klauspost/compress v1.18.0 // indirect 116 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 117 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 118 github.com/lestrrat-go/httpcc v1.0.1 // indirect 119 github.com/lestrrat-go/httprc v1.0.6 // indirect 120 github.com/lestrrat-go/iter v1.0.2 // indirect 121 github.com/lestrrat-go/option v1.0.1 // indirect 122 github.com/mattn/go-isatty v0.0.20 // indirect 123 github.com/minio/sha256-simd v1.0.1 // indirect 124 + github.com/mitchellh/mapstructure v1.5.0 // indirect 125 github.com/moby/docker-image-spec v1.3.1 // indirect 126 github.com/moby/sys/atomicwriter v0.1.0 // indirect 127 github.com/moby/term v0.5.2 // indirect ··· 133 github.com/multiformats/go-multihash v0.2.3 // indirect 134 github.com/multiformats/go-varint v0.0.7 // indirect 135 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 136 + github.com/onsi/gomega v1.37.0 // indirect 137 github.com/opencontainers/go-digest v1.0.0 // indirect 138 github.com/opencontainers/image-spec v1.1.1 // indirect 139 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 140 github.com/pjbgf/sha1cd v0.3.2 // indirect 141 github.com/pkg/errors v0.9.1 // indirect 142 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 143 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 144 github.com/prometheus/client_golang v1.22.0 // indirect 145 github.com/prometheus/client_model v0.6.2 // indirect 146 + github.com/prometheus/common v0.64.0 // indirect 147 github.com/prometheus/procfs v0.16.1 // indirect 148 + github.com/ryanuber/go-glob v1.0.0 // indirect 149 github.com/segmentio/asm v1.2.0 // indirect 150 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 151 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 155 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 156 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 157 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 158 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 159 + go.opentelemetry.io/otel v1.37.0 // indirect 160 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 161 + go.opentelemetry.io/otel/metric v1.37.0 // indirect 162 + go.opentelemetry.io/otel/trace v1.37.0 // indirect 163 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 164 go.uber.org/atomic v1.11.0 // indirect 165 go.uber.org/multierr v1.11.0 // indirect 166 go.uber.org/zap v1.27.0 // indirect 167 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 168 + golang.org/x/sys v0.34.0 // indirect 169 + golang.org/x/text v0.27.0 // indirect 170 + golang.org/x/time v0.12.0 // indirect 171 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 172 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 173 + google.golang.org/grpc v1.73.0 // indirect 174 google.golang.org/protobuf v1.36.6 // indirect 175 gopkg.in/fsnotify.v1 v1.4.7 // indirect 176 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129 -87
go.sum
··· 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 - github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 11 - github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 27 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 - github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 55 - github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 91 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 92 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 93 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 94 - github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 95 - github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 96 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 97 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 98 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 99 - github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 100 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 101 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 102 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 103 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 114 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 115 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 116 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 117 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 118 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 119 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 120 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 121 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 122 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 123 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 124 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 125 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 126 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 127 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 128 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 129 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 130 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 131 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 132 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 133 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 134 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 135 - github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 136 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 137 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 138 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 139 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 146 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 154 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 166 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 167 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 168 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 169 - github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 170 - github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 171 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 172 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 173 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 174 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 175 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 176 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 177 - github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 178 - github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 179 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 180 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 181 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 182 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 183 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 184 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 185 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 189 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 190 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 191 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 192 - github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ= 193 - github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370= 194 - github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q= 195 - github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk= 196 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 197 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 198 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 205 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 206 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 207 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 208 - github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0= 209 - github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0= 210 - github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ= 211 - github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs= 212 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 213 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 214 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 216 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 217 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 218 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 219 - github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE= 220 - github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M= 221 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 222 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 223 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 229 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 230 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 231 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 232 - github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 233 - github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 234 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 235 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 236 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 239 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 240 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 241 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 242 - github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 243 - github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 244 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 245 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 246 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 251 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 252 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 253 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 254 - github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 255 - github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 256 - github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= 257 - github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= 258 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 259 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 260 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 261 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 262 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 265 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 267 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 268 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 269 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 270 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 281 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 282 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 283 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 284 - github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo= 285 - github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= 286 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 287 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 288 - github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= 289 - github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= 290 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 318 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 321 - github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 322 - github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 323 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 324 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 325 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 326 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 327 - github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 328 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 329 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 330 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 331 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 346 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 347 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 348 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 349 - github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 350 - github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 351 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 352 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 353 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 354 - github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 355 - github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 356 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 357 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 358 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 360 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 361 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 362 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 363 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 364 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 365 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 404 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 405 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 406 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 407 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 408 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 409 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 413 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 414 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 415 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 416 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 417 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 418 - go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 419 - go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 420 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= 421 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= 422 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 423 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= 424 - go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 425 - go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 426 - go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 427 - go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 428 - go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= 429 - go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= 430 - go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 431 - go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 432 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 433 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 434 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 451 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 452 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 453 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 454 - golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 455 - golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 456 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 457 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 458 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 459 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 460 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 461 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 462 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 463 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 464 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 465 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 466 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 467 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 468 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 471 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 472 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 473 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 474 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 475 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 476 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 480 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 481 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 482 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 483 - golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 484 - golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 485 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 486 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 489 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 492 - golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 493 - golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 494 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 495 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 496 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 502 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 505 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 506 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 510 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 517 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 518 - golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 519 - golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 520 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 521 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 522 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 523 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 524 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 525 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 526 - golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 527 - golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 528 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 529 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 530 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 532 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 533 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 534 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 535 - golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 536 - golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 537 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 538 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 539 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 547 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 548 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 549 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 550 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 551 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 552 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 553 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 554 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 556 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 557 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 558 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 559 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 560 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= 561 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= 562 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= 563 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 564 - google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 565 - google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 566 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 567 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 568 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 599 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 600 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 601 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 602 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90= 603 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ= 604 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 605 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 93 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 94 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 95 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 96 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 97 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 98 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 99 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 100 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 101 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 102 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 103 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 104 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 105 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 106 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 117 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 118 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 119 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 120 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 121 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 122 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 123 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 125 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 126 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 127 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 128 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 129 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 130 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 131 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 132 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 133 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 134 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 135 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 136 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 137 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 138 + github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 139 + github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 140 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 142 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 143 + github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 144 + github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 145 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 146 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 147 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 154 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 155 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 156 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 157 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 158 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 159 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 160 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 161 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 162 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 163 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 164 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 165 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 166 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 167 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 177 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 178 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 179 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 180 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 181 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 182 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 183 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 184 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 185 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 186 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 187 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 188 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 189 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 190 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 191 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 192 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 193 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 194 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 195 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 196 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 197 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 198 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 199 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 200 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 201 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 202 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 203 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 204 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 205 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 206 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 207 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 208 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 209 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 213 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 214 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 215 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 216 + github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 217 + github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 218 + github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 219 + github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 220 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 221 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 222 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 229 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 230 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 231 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 232 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 233 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 234 + github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 235 + github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 236 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 237 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 238 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 240 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 241 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 242 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 243 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 244 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 245 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 251 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 252 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 253 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 254 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 255 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 256 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 257 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 258 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 261 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 262 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 263 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 264 + github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 265 + github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 266 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 267 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 268 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 273 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 274 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 275 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 276 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 277 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 278 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 279 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 280 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 283 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 284 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 285 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 286 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 287 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 288 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 289 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 290 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 301 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 302 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 303 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 304 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 305 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 306 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 307 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 308 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 334 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 335 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 336 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 337 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 338 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 339 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 340 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 341 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 342 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 343 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 344 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 345 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 346 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 347 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 348 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 349 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 350 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 365 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 366 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 367 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 368 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 369 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 370 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 371 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 372 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 373 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 374 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 375 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 376 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 377 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 379 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 380 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 381 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 382 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 383 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 384 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 385 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 386 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 425 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 426 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 427 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 428 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 429 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 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 435 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 436 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 437 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 438 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= 439 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= 440 + go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 441 + go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 442 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 443 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 444 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 445 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= 446 + go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 447 + go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 448 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 449 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 450 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 451 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 452 + go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 453 + go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 454 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 455 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 456 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 473 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 474 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 475 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 476 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 477 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 478 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 479 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 480 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 481 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 482 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 483 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 484 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 485 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 486 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 487 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 488 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 489 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 490 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 491 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 492 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 493 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 496 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 497 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 498 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 499 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 500 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 501 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 502 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 506 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 507 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 508 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 509 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 510 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 511 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 512 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 513 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 514 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 515 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 517 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 518 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 519 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 520 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 521 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 522 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 523 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 524 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 530 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 531 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 532 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 533 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 534 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 535 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 536 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 537 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 538 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 540 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 541 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 542 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 543 + golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 544 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 545 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 546 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 547 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 548 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 549 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 550 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 551 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 552 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 553 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 554 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 555 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 556 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 557 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 558 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 559 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 560 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 561 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 562 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 563 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 564 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 565 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 566 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 567 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 569 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 570 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 571 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 572 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 573 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 574 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 575 + golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 576 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 577 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 578 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 579 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 580 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 581 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 587 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 588 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 589 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 590 + golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 591 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 592 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 593 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 594 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 595 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 596 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 597 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 598 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 599 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 600 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 601 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 602 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= 603 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= 604 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= 605 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 606 + google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 607 + google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 608 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 609 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 610 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 641 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 642 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 643 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 644 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 645 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 646 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 647 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20 -4
guard/guard.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "net/url" ··· 13 "github.com/bluesky-social/indigo/atproto/identity" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/urfave/cli/v3" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/core/log" 18 ) 19 ··· 43 Usage: "internal API endpoint", 44 Value: "http://localhost:5444", 45 }, 46 }, 47 } 48 } ··· 54 gitDir := cmd.String("git-dir") 55 logPath := cmd.String("log-path") 56 endpoint := cmd.String("internal-api") 57 58 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 if err != nil { ··· 149 "fullPath", fullPath, 150 "client", clientIP) 151 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 154 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 156 } 157 158 gitCmd := exec.Command(gitCommand, fullPath) 159 gitCmd.Stdout = os.Stdout
··· 2 3 import ( 4 "context" 5 + "errors" 6 "fmt" 7 + "io" 8 "log/slog" 9 "net/http" 10 "net/url" ··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/log" 20 ) 21 ··· 45 Usage: "internal API endpoint", 46 Value: "http://localhost:5444", 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 53 }, 54 } 55 } ··· 61 gitDir := cmd.String("git-dir") 62 logPath := cmd.String("log-path") 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 65 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 67 if err != nil { ··· 157 "fullPath", fullPath, 158 "client", clientIP) 159 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 166 } else { 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 171 } 172 + io.Copy(os.Stderr, motdReader) 173 174 gitCmd := exec.Command(gitCommand, fullPath) 175 gitCmd.Stdout = os.Stdout
+24
hook/hook.go
··· 3 import ( 4 "bufio" 5 "context" 6 "fmt" 7 "net/http" 8 "os" ··· 10 11 "github.com/urfave/cli/v3" 12 ) 13 14 // The hook command is nested like so: 15 // ··· 36 Usage: "endpoint for the internal API", 37 Value: "http://localhost:5444", 38 }, 39 }, 40 Commands: []*cli.Command{ 41 { ··· 52 userDid := cmd.String("user-did") 53 userHandle := cmd.String("user-handle") 54 endpoint := cmd.String("internal-api") 55 56 payloadReader := bufio.NewReader(os.Stdin) 57 payload, _ := payloadReader.ReadString('\n') ··· 67 req.Header.Set("X-Git-Dir", gitDir) 68 req.Header.Set("X-Git-User-Did", userDid) 69 req.Header.Set("X-Git-User-Handle", userHandle) 70 71 resp, err := client.Do(req) 72 if err != nil { ··· 76 77 if resp.StatusCode != http.StatusOK { 78 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 79 } 80 81 return nil
··· 3 import ( 4 "bufio" 5 "context" 6 + "encoding/json" 7 "fmt" 8 "net/http" 9 "os" ··· 11 12 "github.com/urfave/cli/v3" 13 ) 14 + 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 18 19 // The hook command is nested like so: 20 // ··· 41 Usage: "endpoint for the internal API", 42 Value: "http://localhost:5444", 43 }, 44 + &cli.StringSliceFlag{ 45 + Name: "push-option", 46 + Usage: "any push option from git", 47 + }, 48 }, 49 Commands: []*cli.Command{ 50 { ··· 61 userDid := cmd.String("user-did") 62 userHandle := cmd.String("user-handle") 63 endpoint := cmd.String("internal-api") 64 + pushOptions := cmd.StringSlice("push-option") 65 66 payloadReader := bufio.NewReader(os.Stdin) 67 payload, _ := payloadReader.ReadString('\n') ··· 77 req.Header.Set("X-Git-Dir", gitDir) 78 req.Header.Set("X-Git-User-Did", userDid) 79 req.Header.Set("X-Git-User-Handle", userHandle) 80 + if pushOptions != nil { 81 + for _, option := range pushOptions { 82 + req.Header.Add("X-Git-Push-Option", option) 83 + } 84 + } 85 86 resp, err := client.Do(req) 87 if err != nil { ··· 91 92 if resp.StatusCode != http.StatusOK { 93 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 94 + } 95 + 96 + var data HookResponse 97 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 98 + return fmt.Errorf("failed to decode response: %w", err) 99 + } 100 + 101 + for _, message := range data.Messages { 102 + fmt.Println(message) 103 } 104 105 return nil
+6 -1
hook/setup.go
··· 133 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 136 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve 137 `, executablePath, config.internalApi) 138 139 return os.WriteFile(hookPath, []byte(hookContent), 0755)
··· 133 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 136 + push_options=() 137 + for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do 138 + option_var="GIT_PUSH_OPTION_$i" 139 + push_options+=(-push-option "${!option_var}") 140 + done 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 142 `, executablePath, config.internalApi) 143 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+116
idresolver/resolver.go
···
··· 1 + package idresolver 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/carlmjohnson/versioninfo" 14 + ) 15 + 16 + type Resolver struct { 17 + directory identity.Directory 18 + } 19 + 20 + func BaseDirectory() identity.Directory { 21 + base := identity.BaseDirectory{ 22 + PLCURL: identity.DefaultPLCURL, 23 + HTTPClient: http.Client{ 24 + Timeout: time.Second * 10, 25 + Transport: &http.Transport{ 26 + // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 27 + IdleConnTimeout: time.Millisecond * 1000, 28 + MaxIdleConns: 100, 29 + }, 30 + }, 31 + Resolver: net.Resolver{ 32 + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 33 + d := net.Dialer{Timeout: time.Second * 3} 34 + return d.DialContext(ctx, network, address) 35 + }, 36 + }, 37 + TryAuthoritativeDNS: true, 38 + // primary Bluesky PDS instance only supports HTTP resolution method 39 + SkipDNSDomainSuffixes: []string{".bsky.social"}, 40 + UserAgent: "indigo-identity/" + versioninfo.Short(), 41 + } 42 + return &base 43 + } 44 + 45 + func RedisDirectory(url string) (identity.Directory, error) { 46 + hitTTL := time.Hour * 24 47 + errTTL := time.Second * 30 48 + invalidHandleTTL := time.Minute * 5 49 + return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 50 + } 51 + 52 + func DefaultResolver() *Resolver { 53 + return &Resolver{ 54 + directory: identity.DefaultDirectory(), 55 + } 56 + } 57 + 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return &Resolver{ 64 + directory: directory, 65 + }, nil 66 + } 67 + 68 + func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 69 + id, err := syntax.ParseAtIdentifier(arg) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + return r.directory.Lookup(ctx, *id) 75 + } 76 + 77 + func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 78 + results := make([]*identity.Identity, len(idents)) 79 + var wg sync.WaitGroup 80 + 81 + done := make(chan struct{}) 82 + defer close(done) 83 + 84 + for idx, ident := range idents { 85 + wg.Add(1) 86 + go func(index int, id string) { 87 + defer wg.Done() 88 + 89 + select { 90 + case <-ctx.Done(): 91 + results[index] = nil 92 + case <-done: 93 + results[index] = nil 94 + default: 95 + identity, _ := r.ResolveIdent(ctx, id) 96 + results[index] = identity 97 + } 98 + }(idx, ident) 99 + } 100 + 101 + wg.Wait() 102 + return results 103 + } 104 + 105 + func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 106 + id, err := syntax.ParseAtIdentifier(arg) 107 + if err != nil { 108 + return err 109 + } 110 + 111 + return r.directory.Purge(ctx, *id) 112 + } 113 + 114 + func (r *Resolver) Directory() identity.Directory { 115 + return r.directory 116 + }
+1 -2
input.css
··· 100 101 .prose img { 102 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 105 vertical-align: middle; 106 } 107 }
··· 100 101 .prose img { 102 display: inline; 103 + margin: 0; 104 vertical-align: middle; 105 } 106 }
+13
jetstream/jetstream.go
··· 52 j.mu.Unlock() 53 } 54 55 type processor func(context.Context, *models.Event) error 56 57 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
··· 52 j.mu.Unlock() 53 } 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 68 type processor func(context.Context, *models.Event) error 69 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
··· 2 3 import ( 4 "context" 5 6 "github.com/sethvargo/go-envconfig" 7 ) 8 ··· 23 24 // This disables signature verification so use with caution. 25 Dev bool `env:"DEV, default=false"` 26 } 27 28 type Config struct {
··· 2 3 import ( 4 "context" 5 + "fmt" 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/sethvargo/go-envconfig" 9 ) 10 ··· 25 26 // This disables signature verification so use with caution. 27 Dev bool `env:"DEV, default=false"` 28 + } 29 + 30 + func (s Server) Did() syntax.DID { 31 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 } 33 34 type Config struct {
+112
knotserver/git/branch.go
···
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "tangled.sh/tangled.sh/core/types" 13 + ) 14 + 15 + func (g *GitRepo) Branches() ([]types.Branch, error) { 16 + fields := []string{ 17 + "refname:short", 18 + "objectname", 19 + "authorname", 20 + "authoremail", 21 + "authordate:unix", 22 + "committername", 23 + "committeremail", 24 + "committerdate:unix", 25 + "tree", 26 + "parent", 27 + "contents", 28 + } 29 + 30 + var outFormat strings.Builder 31 + outFormat.WriteString("--format=") 32 + for i, f := range fields { 33 + if i != 0 { 34 + outFormat.WriteString(fieldSeparator) 35 + } 36 + outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 37 + } 38 + outFormat.WriteString("") 39 + outFormat.WriteString(recordSeparator) 40 + 41 + output, err := g.forEachRef(outFormat.String(), "refs/heads") 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to get branches: %w", err) 44 + } 45 + 46 + records := strings.Split(strings.TrimSpace(string(output)), recordSeparator) 47 + if len(records) == 1 && records[0] == "" { 48 + return nil, nil 49 + } 50 + 51 + branches := make([]types.Branch, 0, len(records)) 52 + 53 + // ignore errors here 54 + defaultBranch, _ := g.FindMainBranch() 55 + 56 + for _, line := range records { 57 + parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields)) 58 + if len(parts) < 6 { 59 + continue 60 + } 61 + 62 + branchName := parts[0] 63 + commitHash := plumbing.NewHash(parts[1]) 64 + authorName := parts[2] 65 + authorEmail := strings.TrimSuffix(strings.TrimPrefix(parts[3], "<"), ">") 66 + authorDate := parts[4] 67 + committerName := parts[5] 68 + committerEmail := strings.TrimSuffix(strings.TrimPrefix(parts[6], "<"), ">") 69 + committerDate := parts[7] 70 + treeHash := plumbing.NewHash(parts[8]) 71 + parentHash := plumbing.NewHash(parts[9]) 72 + message := parts[10] 73 + 74 + // parse creation time 75 + var authoredAt, committedAt time.Time 76 + if unix, err := strconv.ParseInt(authorDate, 10, 64); err == nil { 77 + authoredAt = time.Unix(unix, 0) 78 + } 79 + if unix, err := strconv.ParseInt(committerDate, 10, 64); err == nil { 80 + committedAt = time.Unix(unix, 0) 81 + } 82 + 83 + branch := types.Branch{ 84 + IsDefault: branchName == defaultBranch, 85 + Reference: types.Reference{ 86 + Name: branchName, 87 + Hash: commitHash.String(), 88 + }, 89 + Commit: &object.Commit{ 90 + Hash: commitHash, 91 + Author: object.Signature{ 92 + Name: authorName, 93 + Email: authorEmail, 94 + When: authoredAt, 95 + }, 96 + Committer: object.Signature{ 97 + Name: committerName, 98 + Email: committerEmail, 99 + When: committedAt, 100 + }, 101 + TreeHash: treeHash, 102 + ParentHashes: []plumbing.Hash{parentHash}, 103 + Message: message, 104 + }, 105 + } 106 + 107 + branches = append(branches, branch) 108 + } 109 + 110 + slices.Reverse(branches) 111 + return branches, nil 112 + }
+42
knotserver/git/cmd.go
···
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "os/exec" 6 + ) 7 + 8 + const ( 9 + fieldSeparator = "\x1f" // ASCII Unit Separator 10 + recordSeparator = "\x1e" // ASCII Record Separator 11 + ) 12 + 13 + func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) { 14 + var args []string 15 + args = append(args, command) 16 + args = append(args, extraArgs...) 17 + 18 + cmd := exec.Command("git", args...) 19 + cmd.Dir = g.path 20 + 21 + out, err := cmd.Output() 22 + if err != nil { 23 + if exitErr, ok := err.(*exec.ExitError); ok { 24 + return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 25 + } 26 + return nil, err 27 + } 28 + 29 + return out, nil 30 + } 31 + 32 + func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 33 + return g.runGitCmd("rev-list", extraArgs...) 34 + } 35 + 36 + func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) { 37 + return g.runGitCmd("for-each-ref", extraArgs...) 38 + } 39 + 40 + func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) { 41 + return g.runGitCmd("rev-parse", extraArgs...) 42 + }
+3 -94
knotserver/git/git.go
··· 6 "fmt" 7 "io" 8 "io/fs" 9 - "os/exec" 10 "path" 11 - "sort" 12 "strconv" 13 "strings" 14 "time" ··· 16 "github.com/go-git/go-git/v5" 17 "github.com/go-git/go-git/v5/plumbing" 18 "github.com/go-git/go-git/v5/plumbing/object" 19 - "tangled.sh/tangled.sh/core/types" 20 ) 21 22 var ( ··· 170 return count, nil 171 } 172 173 - func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 174 - var args []string 175 - args = append(args, "rev-list") 176 - args = append(args, extraArgs...) 177 - 178 - cmd := exec.Command("git", args...) 179 - cmd.Dir = g.path 180 - 181 - out, err := cmd.Output() 182 - if err != nil { 183 - if exitErr, ok := err.(*exec.ExitError); ok { 184 - return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 185 - } 186 - return nil, err 187 - } 188 - 189 - return out, nil 190 - } 191 - 192 func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { 193 return g.r.CommitObject(h) 194 } ··· 285 return io.ReadAll(reader) 286 } 287 288 - func (g *GitRepo) Tags() ([]*TagReference, error) { 289 - iter, err := g.r.Tags() 290 - if err != nil { 291 - return nil, fmt.Errorf("tag objects: %w", err) 292 - } 293 - 294 - tags := make([]*TagReference, 0) 295 - 296 - if err := iter.ForEach(func(ref *plumbing.Reference) error { 297 - obj, err := g.r.TagObject(ref.Hash()) 298 - switch err { 299 - case nil: 300 - tags = append(tags, &TagReference{ 301 - ref: ref, 302 - tag: obj, 303 - }) 304 - case plumbing.ErrObjectNotFound: 305 - tags = append(tags, &TagReference{ 306 - ref: ref, 307 - }) 308 - default: 309 - return err 310 - } 311 - return nil 312 - }); err != nil { 313 - return nil, err 314 - } 315 - 316 - tagList := &TagList{r: g.r, refs: tags} 317 - sort.Sort(tagList) 318 - return tags, nil 319 - } 320 - 321 - func (g *GitRepo) Branches() ([]types.Branch, error) { 322 - bi, err := g.r.Branches() 323 - if err != nil { 324 - return nil, fmt.Errorf("branchs: %w", err) 325 - } 326 - 327 - branches := []types.Branch{} 328 - 329 - defaultBranch, err := g.FindMainBranch() 330 - 331 - _ = bi.ForEach(func(ref *plumbing.Reference) error { 332 - b := types.Branch{} 333 - b.Hash = ref.Hash().String() 334 - b.Name = ref.Name().Short() 335 - 336 - // resolve commit that this branch points to 337 - commit, _ := g.Commit(ref.Hash()) 338 - if commit != nil { 339 - b.Commit = commit 340 - } 341 - 342 - if defaultBranch != "" && defaultBranch == b.Name { 343 - b.IsDefault = true 344 - } 345 - 346 - branches = append(branches, b) 347 - 348 - return nil 349 - }) 350 - 351 - return branches, nil 352 - } 353 - 354 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 355 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 356 if err != nil { ··· 370 } 371 372 func (g *GitRepo) FindMainBranch() (string, error) { 373 - ref, err := g.r.Head() 374 if err != nil { 375 - return "", fmt.Errorf("unable to find main branch: %w", err) 376 - } 377 - if ref.Name().IsBranch() { 378 - return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil 379 } 380 381 - return "", fmt.Errorf("unable to find main branch: %w", err) 382 } 383 384 // WriteTar writes itself from a tree into a binary tar file format.
··· 6 "fmt" 7 "io" 8 "io/fs" 9 "path" 10 "strconv" 11 "strings" 12 "time" ··· 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 "github.com/go-git/go-git/v5/plumbing/object" 17 ) 18 19 var ( ··· 167 return count, nil 168 } 169 170 func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { 171 return g.r.CommitObject(h) 172 } ··· 263 return io.ReadAll(reader) 264 } 265 266 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 267 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 268 if err != nil { ··· 282 } 283 284 func (g *GitRepo) FindMainBranch() (string, error) { 285 + output, err := g.revParse("--abbrev-ref", "HEAD") 286 if err != nil { 287 + return "", fmt.Errorf("failed to find main branch: %w", err) 288 } 289 290 + return strings.TrimSpace(string(output)), nil 291 } 292 293 // WriteTar writes itself from a tree into a binary tar file format.
+66
knotserver/git/language.go
···
··· 1 + package git 2 + 3 + import ( 4 + "context" 5 + "path" 6 + 7 + "github.com/go-enry/go-enry/v2" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + ) 10 + 11 + type LangBreakdown map[string]int64 12 + 13 + func (g *GitRepo) AnalyzeLanguages(ctx context.Context) (LangBreakdown, error) { 14 + sizes := make(map[string]int64) 15 + err := g.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error { 16 + filepath := path.Join(root, node.Name) 17 + 18 + content, err := g.FileContentN(filepath, 16*1024) // 16KB 19 + if err != nil { 20 + return nil 21 + } 22 + 23 + if enry.IsGenerated(filepath, content) { 24 + return nil 25 + } 26 + 27 + language := analyzeLanguage(node, content) 28 + if group := enry.GetLanguageGroup(language); group != "" { 29 + language = group 30 + } 31 + 32 + langType := enry.GetLanguageType(language) 33 + if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown { 34 + return nil 35 + } 36 + 37 + sz, _ := parent.Size(node.Name) 38 + sizes[language] += sz 39 + 40 + return nil 41 + }) 42 + 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + return sizes, nil 48 + } 49 + 50 + func analyzeLanguage(node object.TreeEntry, content []byte) string { 51 + language, ok := enry.GetLanguageByExtension(node.Name) 52 + if ok { 53 + return language 54 + } 55 + 56 + language, ok = enry.GetLanguageByFilename(node.Name) 57 + if ok { 58 + return language 59 + } 60 + 61 + if len(content) == 0 { 62 + return enry.OtherLanguage 63 + } 64 + 65 + return enry.GetLanguage(node.Name, content) 66 + }
+57 -25
knotserver/git/post_receive.go
··· 2 3 import ( 4 "bufio" 5 "fmt" 6 "io" 7 "strings" 8 9 "tangled.sh/tangled.sh/core/api/tangled" 10 ··· 46 } 47 48 type RefUpdateMeta struct { 49 - CommitCount CommitCount 50 - IsDefaultRef bool 51 } 52 53 type CommitCount struct { ··· 57 func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 58 commitCount, err := g.newCommitCount(line) 59 if err != nil { 60 - // TODO: non-fatal, log this 61 } 62 63 isDefaultRef, err := g.isDefaultBranch(line) 64 if err != nil { 65 - // TODO: non-fatal, log this 66 } 67 68 return RefUpdateMeta{ 69 - CommitCount: commitCount, 70 - IsDefaultRef: isDefaultRef, 71 } 72 } 73 ··· 77 ByEmail: byEmail, 78 } 79 80 - if !line.NewSha.IsZero() { 81 - output, err := g.revList( 82 - fmt.Sprintf("--max-count=%d", 100), 83 - fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()), 84 - ) 85 - if err != nil { 86 - return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 87 - } 88 89 - lines := strings.Split(strings.TrimSpace(string(output)), "\n") 90 - if len(lines) == 1 && lines[0] == "" { 91 - return commitCount, nil 92 - } 93 94 - for _, item := range lines { 95 - obj, err := g.r.CommitObject(plumbing.NewHash(item)) 96 - if err != nil { 97 - continue 98 - } 99 - commitCount.ByEmail[obj.Author.Email] += 1 100 } 101 } 102 103 return commitCount, nil ··· 126 }) 127 } 128 129 return tangled.GitRefUpdate_Meta{ 130 CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 131 ByEmail: byEmail, 132 }, 133 - IsDefaultRef: m.IsDefaultRef, 134 } 135 }
··· 2 3 import ( 4 "bufio" 5 + "context" 6 "fmt" 7 "io" 8 "strings" 9 + "time" 10 11 "tangled.sh/tangled.sh/core/api/tangled" 12 ··· 48 } 49 50 type RefUpdateMeta struct { 51 + CommitCount CommitCount 52 + IsDefaultRef bool 53 + LangBreakdown LangBreakdown 54 } 55 56 type CommitCount struct { ··· 60 func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 commitCount, err := g.newCommitCount(line) 62 if err != nil { 63 + // TODO: log this 64 } 65 66 isDefaultRef, err := g.isDefaultBranch(line) 67 if err != nil { 68 + // TODO: log this 69 + } 70 + 71 + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 + defer cancel() 73 + breakdown, err := g.AnalyzeLanguages(ctx) 74 + if err != nil { 75 + // TODO: log this 76 } 77 78 return RefUpdateMeta{ 79 + CommitCount: commitCount, 80 + IsDefaultRef: isDefaultRef, 81 + LangBreakdown: breakdown, 82 } 83 } 84 ··· 88 ByEmail: byEmail, 89 } 90 91 + if line.NewSha.IsZero() { 92 + return commitCount, nil 93 + } 94 95 + args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 97 + if line.OldSha.IsZero() { 98 + // just git rev-list <newsha> 99 + args = append(args, line.NewSha.String()) 100 + } else { 101 + // git rev-list <oldsha>..<newsha> 102 + args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String())) 103 + } 104 + 105 + output, err := g.revList(args...) 106 + if err != nil { 107 + return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 108 + } 109 + 110 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 111 + if len(lines) == 1 && lines[0] == "" { 112 + return commitCount, nil 113 + } 114 + 115 + for _, item := range lines { 116 + obj, err := g.r.CommitObject(plumbing.NewHash(item)) 117 + if err != nil { 118 + continue 119 } 120 + commitCount.ByEmail[obj.Author.Email] += 1 121 } 122 123 return commitCount, nil ··· 146 }) 147 } 148 149 + var langs []*tangled.GitRefUpdate_Pair 150 + for lang, size := range m.LangBreakdown { 151 + langs = append(langs, &tangled.GitRefUpdate_Pair{ 152 + Lang: lang, 153 + Size: size, 154 + }) 155 + } 156 + langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 157 + Inputs: langs, 158 + } 159 + 160 return tangled.GitRefUpdate_Meta{ 161 CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 162 ByEmail: byEmail, 163 }, 164 + IsDefaultRef: m.IsDefaultRef, 165 + LangBreakdown: langBreakdown, 166 } 167 }
+99
knotserver/git/tag.go
···
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + ) 13 + 14 + func (g *GitRepo) Tags() ([]object.Tag, error) { 15 + fields := []string{ 16 + "refname:short", 17 + "objectname", 18 + "objecttype", 19 + "*objectname", 20 + "*objecttype", 21 + "taggername", 22 + "taggeremail", 23 + "taggerdate:unix", 24 + "contents", 25 + } 26 + 27 + var outFormat strings.Builder 28 + outFormat.WriteString("--format=") 29 + for i, f := range fields { 30 + if i != 0 { 31 + outFormat.WriteString(fieldSeparator) 32 + } 33 + outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 34 + } 35 + outFormat.WriteString("") 36 + outFormat.WriteString(recordSeparator) 37 + 38 + output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 + if err != nil { 40 + return nil, fmt.Errorf("failed to get tags: %w", err) 41 + } 42 + 43 + records := strings.Split(strings.TrimSpace(string(output)), recordSeparator) 44 + if len(records) == 1 && records[0] == "" { 45 + return nil, nil 46 + } 47 + 48 + tags := make([]object.Tag, 0, len(records)) 49 + 50 + for _, line := range records { 51 + parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields)) 52 + if len(parts) < 6 { 53 + continue 54 + } 55 + 56 + tagName := parts[0] 57 + objectHash := parts[1] 58 + objectType := parts[2] 59 + targetHash := parts[3] // dereferenced object hash (empty for lightweight tags) 60 + // targetType := parts[4] // dereferenced object type (empty for lightweight tags) 61 + taggerName := parts[5] 62 + taggerEmail := parts[6] 63 + taggerDate := parts[7] 64 + message := parts[8] 65 + 66 + // parse creation time 67 + var createdAt time.Time 68 + if unix, err := strconv.ParseInt(taggerDate, 10, 64); err == nil { 69 + createdAt = time.Unix(unix, 0) 70 + } 71 + 72 + // parse object type 73 + typ, err := plumbing.ParseObjectType(objectType) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + // strip email separators 79 + taggerEmail = strings.TrimSuffix(strings.TrimPrefix(taggerEmail, "<"), ">") 80 + 81 + tag := object.Tag{ 82 + Hash: plumbing.NewHash(objectHash), 83 + Name: tagName, 84 + Tagger: object.Signature{ 85 + Name: taggerName, 86 + Email: taggerEmail, 87 + When: createdAt, 88 + }, 89 + Message: message, 90 + TargetType: typ, 91 + Target: plumbing.NewHash(targetHash), 92 + } 93 + 94 + tags = append(tags, tag) 95 + } 96 + 97 + slices.Reverse(tags) 98 + return tags, nil 99 + }
+37 -18
knotserver/handler.go
··· 8 "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 "tangled.sh/tangled.sh/core/jetstream" 12 "tangled.sh/tangled.sh/core/knotserver/config" 13 "tangled.sh/tangled.sh/core/knotserver/db" 14 "tangled.sh/tangled.sh/core/notifier" 15 "tangled.sh/tangled.sh/core/rbac" 16 - ) 17 - 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 ) 21 22 type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 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. ··· 37 r := chi.NewRouter() 38 39 h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - init: make(chan struct{}), 47 } 48 49 - err := e.AddKnot(ThisServer) 50 if err != nil { 51 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 52 } ··· 131 }) 132 }) 133 134 // Create a new repository. 135 r.Route("/repo", func(r chi.Router) { 136 r.Use(h.VerifySignature) ··· 161 r.Get("/keys", h.Keys) 162 163 return r, nil 164 } 165 166 // version is set during build time.
··· 8 "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 "tangled.sh/tangled.sh/core/jetstream" 13 "tangled.sh/tangled.sh/core/knotserver/config" 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 "tangled.sh/tangled.sh/core/notifier" 18 "tangled.sh/tangled.sh/core/rbac" 19 ) 20 21 type Handle struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 30 // init is a channel that is closed when the knot has been initailized 31 // i.e. when the first user (knot owner) has been added. ··· 37 r := chi.NewRouter() 38 39 h := Handle{ 40 + c: c, 41 + db: db, 42 + e: e, 43 + l: l, 44 + jc: jc, 45 + n: n, 46 + resolver: idresolver.DefaultResolver(), 47 + init: make(chan struct{}), 48 } 49 50 + err := e.AddKnot(rbac.ThisServer) 51 if err != nil { 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 } ··· 132 }) 133 }) 134 135 + // xrpc apis 136 + r.Mount("/xrpc", h.XrpcRouter()) 137 + 138 // Create a new repository. 139 r.Route("/repo", func(r chi.Router) { 140 r.Use(h.VerifySignature) ··· 165 r.Get("/keys", h.Keys) 166 167 return r, nil 168 + } 169 + 170 + func (h *Handle) XrpcRouter() http.Handler { 171 + logger := tlog.New("knots") 172 + 173 + xrpc := &xrpc.Xrpc{ 174 + Config: h.c, 175 + Db: h.db, 176 + Ingester: h.jc, 177 + Enforcer: h.e, 178 + Logger: logger, 179 + Notifier: h.n, 180 + Resolver: h.resolver, 181 + } 182 + return xrpc.Router() 183 } 184 185 // version is set during build time.
+65 -4
knotserver/ingester.go
··· 17 "github.com/bluesky-social/jetstream/pkg/models" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 "tangled.sh/tangled.sh/core/log" 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 ··· 46 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 47 } 48 49 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 50 if err != nil || !ok { 51 l.Error("failed to add member", "did", did) 52 return fmt.Errorf("failed to enforce permissions: %w", err) 53 } 54 55 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 56 l.Error("failed to add member", "error", err) 57 return fmt.Errorf("failed to add member: %w", err) 58 } ··· 212 return h.db.InsertEvent(event, h.n) 213 } 214 215 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 216 l := log.FromContext(ctx) 217 ··· 265 defer func() { 266 eventTime := event.TimeUS 267 lastTimeUs := eventTime + 1 268 - fmt.Println("lastTimeUs", lastTimeUs) 269 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 270 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 271 } ··· 291 if err := h.processKnotMember(ctx, did, record); err != nil { 292 return fmt.Errorf("failed to process knot member: %w", err) 293 } 294 case tangled.RepoPullNSID: 295 var record tangled.RepoPull 296 if err := json.Unmarshal(raw, &record); err != nil { ··· 299 if err := h.processPull(ctx, did, record); err != nil { 300 return fmt.Errorf("failed to process knot member: %w", err) 301 } 302 } 303 304 return err
··· 17 "github.com/bluesky-social/jetstream/pkg/models" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/rbac" 25 "tangled.sh/tangled.sh/core/workflow" 26 ) 27 ··· 47 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 48 } 49 50 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 51 if err != nil || !ok { 52 l.Error("failed to add member", "did", did) 53 return fmt.Errorf("failed to enforce permissions: %w", err) 54 } 55 56 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 57 l.Error("failed to add member", "error", err) 58 return fmt.Errorf("failed to add member: %w", err) 59 } ··· 213 return h.db.InsertEvent(event, h.n) 214 } 215 216 + // duplicated from add collaborator 217 + func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 218 + repoAt, err := syntax.ParseATURI(record.Repo) 219 + if err != nil { 220 + return err 221 + } 222 + 223 + resolver := idresolver.DefaultResolver() 224 + 225 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + if err != nil || subjectId.Handle.IsInvalidHandle() { 227 + return err 228 + } 229 + 230 + // TODO: fix this for good, we need to fetch the record here unfortunately 231 + // resolve this aturi to extract the repo record 232 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 233 + if err != nil || owner.Handle.IsInvalidHandle() { 234 + return fmt.Errorf("failed to resolve handle: %w", err) 235 + } 236 + 237 + xrpcc := xrpc.Client{ 238 + Host: owner.PDSEndpoint(), 239 + } 240 + 241 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 242 + if err != nil { 243 + return err 244 + } 245 + 246 + repo := resp.Value.Val.(*tangled.Repo) 247 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 + 249 + // 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) 252 + } 253 + 254 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 255 + return err 256 + } 257 + h.jc.AddDid(subjectId.DID.String()) 258 + 259 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 260 + return err 261 + } 262 + 263 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 264 + } 265 + 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 267 l := log.FromContext(ctx) 268 ··· 316 defer func() { 317 eventTime := event.TimeUS 318 lastTimeUs := eventTime + 1 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 321 } ··· 341 if err := h.processKnotMember(ctx, did, record); err != nil { 342 return fmt.Errorf("failed to process knot member: %w", err) 343 } 344 + 345 case tangled.RepoPullNSID: 346 var record tangled.RepoPull 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 350 if err := h.processPull(ctx, did, record); err != nil { 351 return fmt.Errorf("failed to process knot member: %w", err) 352 } 353 + 354 + 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 + } 362 + 363 } 364 365 return err
+66 -7
knotserver/internal.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "log/slog" 7 "net/http" 8 "path/filepath" ··· 12 "github.com/go-chi/chi/v5" 13 "github.com/go-chi/chi/v5/middleware" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/knotserver/config" 16 "tangled.sh/tangled.sh/core/knotserver/db" 17 "tangled.sh/tangled.sh/core/knotserver/git" ··· 37 return 38 } 39 40 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 41 if err != nil || !ok { 42 w.WriteHeader(http.StatusForbidden) 43 return ··· 63 return 64 } 65 66 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 67 l := h.l.With("handler", "PostReceiveHook") 68 ··· 89 // non-fatal 90 } 91 92 for _, line := range lines { 93 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 94 if err != nil { ··· 96 // non-fatal 97 } 98 99 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 100 if err != nil { 101 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 102 // non-fatal 103 } 104 } 105 } 106 107 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 115 return err 116 } 117 118 - gr, err := git.PlainOpen(repoPath) 119 if err != nil { 120 - return err 121 } 122 123 meta := gr.RefUpdateMeta(line) 124 metaRecord := meta.AsRecord() 125 126 refUpdate := tangled.GitRefUpdate{ ··· 146 return h.db.InsertEvent(event, h.n) 147 } 148 149 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 150 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 151 if err != nil { 152 return err ··· 166 if err != nil { 167 return err 168 } 169 170 var pipeline workflow.Pipeline 171 for _, e := range workflowDir { ··· 181 182 wf, err := workflow.FromFile(e.Name, contents) 183 if err != nil { 184 - // TODO: log here, respond to client that is pushing 185 h.l.Error("failed to parse workflow", "err", err, "path", fpath) 186 continue 187 } 188 ··· 207 }, 208 } 209 210 - // TODO: send the diagnostics back to the user here via stderr 211 cp := compiler.Compile(pipeline) 212 eventJson, err := json.Marshal(cp) 213 if err != nil { 214 return err 215 } 216 217 // do not run empty pipelines
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "fmt" 7 "log/slog" 8 "net/http" 9 "path/filepath" ··· 13 "github.com/go-chi/chi/v5" 14 "github.com/go-chi/chi/v5/middleware" 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/hook" 17 "tangled.sh/tangled.sh/core/knotserver/config" 18 "tangled.sh/tangled.sh/core/knotserver/db" 19 "tangled.sh/tangled.sh/core/knotserver/git" ··· 39 return 40 } 41 42 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 43 if err != nil || !ok { 44 w.WriteHeader(http.StatusForbidden) 45 return ··· 65 return 66 } 67 68 + type PushOptions struct { 69 + skipCi bool 70 + verboseCi bool 71 + } 72 + 73 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 74 l := h.l.With("handler", "PostReceiveHook") 75 ··· 96 // non-fatal 97 } 98 99 + // extract any push options 100 + pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 101 + pushOptions := PushOptions{} 102 + for _, option := range pushOptionsRaw { 103 + if option == "skip-ci" || option == "ci-skip" { 104 + pushOptions.skipCi = true 105 + } 106 + if option == "verbose-ci" || option == "ci-verbose" { 107 + pushOptions.verboseCi = true 108 + } 109 + } 110 + 111 + resp := hook.HookResponse{ 112 + Messages: make([]string, 0), 113 + } 114 + 115 for _, line := range lines { 116 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 117 if err != nil { ··· 119 // non-fatal 120 } 121 122 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 123 if err != nil { 124 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 125 // non-fatal 126 } 127 } 128 + 129 + writeJSON(w, resp) 130 } 131 132 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 140 return err 141 } 142 143 + gr, err := git.Open(repoPath, line.Ref) 144 if err != nil { 145 + return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 } 147 148 meta := gr.RefUpdateMeta(line) 149 + 150 metaRecord := meta.AsRecord() 151 152 refUpdate := tangled.GitRefUpdate{ ··· 172 return h.db.InsertEvent(event, h.n) 173 } 174 175 + func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 176 + if pushOptions.skipCi { 177 + return nil 178 + } 179 + 180 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 181 if err != nil { 182 return err ··· 196 if err != nil { 197 return err 198 } 199 + 200 + pipelineParseErrors := []string{} 201 202 var pipeline workflow.Pipeline 203 for _, e := range workflowDir { ··· 213 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 ··· 239 }, 240 } 241 242 cp := compiler.Compile(pipeline) 243 eventJson, err := json.Marshal(cp) 244 if err != nil { 245 return err 246 + } 247 + 248 + 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 + } 256 + } 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") 273 + } 274 } 275 276 // do not run empty pipelines
+38 -70
knotserver/routes.go
··· 13 "net/http" 14 "net/url" 15 "os" 16 - "path" 17 "path/filepath" 18 "strconv" 19 "strings" ··· 23 securejoin "github.com/cyphar/filepath-securejoin" 24 "github.com/gliderlabs/ssh" 25 "github.com/go-chi/chi/v5" 26 - "github.com/go-enry/go-enry/v2" 27 gogit "github.com/go-git/go-git/v5" 28 "github.com/go-git/go-git/v5/plumbing" 29 "github.com/go-git/go-git/v5/plumbing/object" ··· 31 "tangled.sh/tangled.sh/core/knotserver/db" 32 "tangled.sh/tangled.sh/core/knotserver/git" 33 "tangled.sh/tangled.sh/core/patchutil" 34 "tangled.sh/tangled.sh/core/types" 35 ) 36 ··· 96 total int 97 branches []types.Branch 98 files []types.NiceTree 99 - tags []*git.TagReference 100 ) 101 102 var wg sync.WaitGroup ··· 169 170 rtags := []*types.TagReference{} 171 for _, tag := range tags { 172 tr := types.TagReference{ 173 - Tag: tag.TagObject(), 174 } 175 176 tr.Reference = types.Reference{ 177 - Name: tag.Name(), 178 - Hash: tag.Hash().String(), 179 } 180 181 - if tag.Message() != "" { 182 - tr.Message = tag.Message() 183 } 184 185 rtags = append(rtags, &tr) ··· 283 mimeType = "image/svg+xml" 284 } 285 286 - if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 287 - l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 288 - writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 289 return 290 } 291 ··· 488 489 rtags := []*types.TagReference{} 490 for _, tag := range tags { 491 tr := types.TagReference{ 492 - Tag: tag.TagObject(), 493 } 494 495 tr.Reference = types.Reference{ 496 - Name: tag.Name(), 497 - Hash: tag.Hash().String(), 498 } 499 500 - if tag.Message() != "" { 501 - tr.Message = tag.Message() 502 } 503 504 rtags = append(rtags, &tr) ··· 668 } 669 670 // add perms for this user to access the repo 671 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 672 if err != nil { 673 l.Error("adding repo permissions", "error", err.Error()) 674 writeError(w, err.Error(), http.StatusInternalServerError) ··· 777 return 778 } 779 780 - sizes := make(map[string]int64) 781 - 782 ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 783 defer cancel() 784 785 - err = gr.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error { 786 - filepath := path.Join(root, node.Name) 787 - 788 - content, err := gr.FileContentN(filepath, 16*1024) // 16KB 789 - if err != nil { 790 - return nil 791 - } 792 - 793 - if enry.IsGenerated(filepath, content) { 794 - return nil 795 - } 796 - 797 - language := analyzeLanguage(node, content) 798 - if group := enry.GetLanguageGroup(language); group != "" { 799 - language = group 800 - } 801 - 802 - langType := enry.GetLanguageType(language) 803 - if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown { 804 - return nil 805 - } 806 - 807 - sz, _ := parent.Size(node.Name) 808 - sizes[language] += sz 809 - 810 - return nil 811 - }) 812 if err != nil { 813 - l.Error("failed to recurse file tree", "error", err.Error()) 814 writeError(w, err.Error(), http.StatusNoContent) 815 return 816 } ··· 818 resp := types.RepoLanguageResponse{Languages: sizes} 819 820 writeJSON(w, resp) 821 - return 822 - } 823 - 824 - func analyzeLanguage(node object.TreeEntry, content []byte) string { 825 - language, ok := enry.GetLanguageByExtension(node.Name) 826 - if ok { 827 - return language 828 - } 829 - 830 - language, ok = enry.GetLanguageByFilename(node.Name) 831 - if ok { 832 - return language 833 - } 834 - 835 - if len(content) == 0 { 836 - return enry.OtherLanguage 837 - } 838 - 839 - return enry.GetLanguage(node.Name, content) 840 } 841 842 func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { ··· 933 } 934 935 // add perms for this user to access the repo 936 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 937 if err != nil { 938 l.Error("adding repo permissions", "error", err.Error()) 939 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1187 } 1188 h.jc.AddDid(did) 1189 1190 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1191 l.Error("adding member", "error", err.Error()) 1192 writeError(w, err.Error(), http.StatusInternalServerError) 1193 return ··· 1225 h.jc.AddDid(data.Did) 1226 1227 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1228 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1229 l.Error("adding repo collaborator", "error", err.Error()) 1230 writeError(w, err.Error(), http.StatusInternalServerError) 1231 return ··· 1322 } 1323 h.jc.AddDid(data.Did) 1324 1325 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1326 l.Error("adding owner", "error", err.Error()) 1327 writeError(w, err.Error(), http.StatusInternalServerError) 1328 return
··· 13 "net/http" 14 "net/url" 15 "os" 16 "path/filepath" 17 "strconv" 18 "strings" ··· 22 securejoin "github.com/cyphar/filepath-securejoin" 23 "github.com/gliderlabs/ssh" 24 "github.com/go-chi/chi/v5" 25 gogit "github.com/go-git/go-git/v5" 26 "github.com/go-git/go-git/v5/plumbing" 27 "github.com/go-git/go-git/v5/plumbing/object" ··· 29 "tangled.sh/tangled.sh/core/knotserver/db" 30 "tangled.sh/tangled.sh/core/knotserver/git" 31 "tangled.sh/tangled.sh/core/patchutil" 32 + "tangled.sh/tangled.sh/core/rbac" 33 "tangled.sh/tangled.sh/core/types" 34 ) 35 ··· 95 total int 96 branches []types.Branch 97 files []types.NiceTree 98 + tags []object.Tag 99 ) 100 101 var wg sync.WaitGroup ··· 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) ··· 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 ··· 499 500 rtags := []*types.TagReference{} 501 for _, tag := range tags { 502 + var target *object.Tag 503 + if tag.Target != plumbing.ZeroHash { 504 + target = &tag 505 + } 506 tr := types.TagReference{ 507 + Tag: target, 508 } 509 510 tr.Reference = types.Reference{ 511 + Name: tag.Name, 512 + Hash: tag.Hash.String(), 513 } 514 515 + if tag.Message != "" { 516 + tr.Message = tag.Message 517 } 518 519 rtags = append(rtags, &tr) ··· 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) ··· 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 } ··· 805 resp := types.RepoLanguageResponse{Languages: sizes} 806 807 writeJSON(w, resp) 808 } 809 810 func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { ··· 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) ··· 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 ··· 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 ··· 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
+1
knotserver/server.go
··· 76 tangled.PublicKeyNSID, 77 tangled.KnotMemberNSID, 78 tangled.RepoPullNSID, 79 }, nil, logger, db, true, c.Server.LogDids) 80 if err != nil { 81 logger.Error("failed to setup jetstream", "error", err)
··· 76 tangled.PublicKeyNSID, 77 tangled.KnotMemberNSID, 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 80 }, nil, logger, db, true, c.Server.LogDids) 81 if err != nil { 82 logger.Error("failed to setup jetstream", "error", err)
-5
knotserver/util.go
··· 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "github.com/go-chi/chi/v5" 11 - "github.com/microcosm-cc/bluemonday" 12 ) 13 - 14 - func sanitize(content []byte) []byte { 15 - return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 16 - } 17 18 func didPath(r *http.Request) string { 19 did := chi.URLParam(r, "did")
··· 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "github.com/go-chi/chi/v5" 11 ) 12 13 func didPath(r *http.Request) string { 14 did := chi.URLParam(r, "did")
+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 + }
+87
knotserver/xrpc/set_default_branch.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 + ) 16 + 17 + const ActorDid string = "ActorDid" 18 + 19 + func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e 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(MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoSetDefaultBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 + if err != nil { 62 + fail(GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(InvalidRepoError(data.Repo)) 76 + return 77 + } 78 + 79 + err = gr.SetDefaultBranch(data.DefaultBranch) 80 + if err != nil { 81 + l.Error("setting default branch", "error", err.Error()) 82 + writeError(w, GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
···
+34
lexicons/feed/reaction.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.reaction", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "reaction", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "reaction": { 23 + "type": "string", 24 + "enum": [ "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ˜†", "๐ŸŽ‰", "๐Ÿซค", "โค๏ธ", "๐Ÿš€", "๐Ÿ‘€" ] 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+27
lexicons/git/refUpdate.json
··· 61 "type": "boolean", 62 "default": "false" 63 }, 64 "commitCount": { 65 "type": "object", 66 "required": [], ··· 87 } 88 } 89 } 90 } 91 } 92 }
··· 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": [], ··· 99 } 100 } 101 } 102 + } 103 + } 104 + }, 105 + "pair": { 106 + "type": "object", 107 + "required": [ 108 + "lang", 109 + "size" 110 + ], 111 + "properties": { 112 + "lang": { 113 + "type": "string" 114 + }, 115 + "size": { 116 + "type": "integer" 117 } 118 } 119 }
+263
lexicons/pipeline/pipeline.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "dependencies", 153 + "steps", 154 + "environment", 155 + "clone" 156 + ], 157 + "properties": { 158 + "name": { 159 + "type": "string" 160 + }, 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 + } 181 + }, 182 + "clone": { 183 + "type": "ref", 184 + "ref": "#cloneOpts" 185 + } 186 + } 187 + }, 188 + "dependency": { 189 + "type": "object", 190 + "required": [ 191 + "registry", 192 + "packages" 193 + ], 194 + "properties": { 195 + "registry": { 196 + "type": "string" 197 + }, 198 + "packages": { 199 + "type": "array", 200 + "items": { 201 + "type": "string" 202 + } 203 + } 204 + } 205 + }, 206 + "cloneOpts": { 207 + "type": "object", 208 + "required": [ 209 + "skip", 210 + "depth", 211 + "submodules" 212 + ], 213 + "properties": { 214 + "skip": { 215 + "type": "boolean" 216 + }, 217 + "depth": { 218 + "type": "integer" 219 + }, 220 + "submodules": { 221 + "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 + } 245 + } 246 + }, 247 + "pair": { 248 + "type": "object", 249 + "required": [ 250 + "key", 251 + "value" 252 + ], 253 + "properties": { 254 + "key": { 255 + "type": "string" 256 + }, 257 + "value": { 258 + "type": "string" 259 + } 260 + } 261 + } 262 + } 263 + }
-263
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "triggerMetadata", 14 - "workflows" 15 - ], 16 - "properties": { 17 - "triggerMetadata": { 18 - "type": "ref", 19 - "ref": "#triggerMetadata" 20 - }, 21 - "workflows": { 22 - "type": "array", 23 - "items": { 24 - "type": "ref", 25 - "ref": "#workflow" 26 - } 27 - } 28 - } 29 - } 30 - }, 31 - "triggerMetadata": { 32 - "type": "object", 33 - "required": [ 34 - "kind", 35 - "repo" 36 - ], 37 - "properties": { 38 - "kind": { 39 - "type": "string", 40 - "enum": [ 41 - "push", 42 - "pull_request", 43 - "manual" 44 - ] 45 - }, 46 - "repo": { 47 - "type": "ref", 48 - "ref": "#triggerRepo" 49 - }, 50 - "push": { 51 - "type": "ref", 52 - "ref": "#pushTriggerData" 53 - }, 54 - "pullRequest": { 55 - "type": "ref", 56 - "ref": "#pullRequestTriggerData" 57 - }, 58 - "manual": { 59 - "type": "ref", 60 - "ref": "#manualTriggerData" 61 - } 62 - } 63 - }, 64 - "triggerRepo": { 65 - "type": "object", 66 - "required": [ 67 - "knot", 68 - "did", 69 - "repo", 70 - "defaultBranch" 71 - ], 72 - "properties": { 73 - "knot": { 74 - "type": "string" 75 - }, 76 - "did": { 77 - "type": "string", 78 - "format": "did" 79 - }, 80 - "repo": { 81 - "type": "string" 82 - }, 83 - "defaultBranch": { 84 - "type": "string" 85 - } 86 - } 87 - }, 88 - "pushTriggerData": { 89 - "type": "object", 90 - "required": [ 91 - "ref", 92 - "newSha", 93 - "oldSha" 94 - ], 95 - "properties": { 96 - "ref": { 97 - "type": "string" 98 - }, 99 - "newSha": { 100 - "type": "string", 101 - "minLength": 40, 102 - "maxLength": 40 103 - }, 104 - "oldSha": { 105 - "type": "string", 106 - "minLength": 40, 107 - "maxLength": 40 108 - } 109 - } 110 - }, 111 - "pullRequestTriggerData": { 112 - "type": "object", 113 - "required": [ 114 - "sourceBranch", 115 - "targetBranch", 116 - "sourceSha", 117 - "action" 118 - ], 119 - "properties": { 120 - "sourceBranch": { 121 - "type": "string" 122 - }, 123 - "targetBranch": { 124 - "type": "string" 125 - }, 126 - "sourceSha": { 127 - "type": "string", 128 - "minLength": 40, 129 - "maxLength": 40 130 - }, 131 - "action": { 132 - "type": "string" 133 - } 134 - } 135 - }, 136 - "manualTriggerData": { 137 - "type": "object", 138 - "properties": { 139 - "inputs": { 140 - "type": "array", 141 - "items": { 142 - "type": "ref", 143 - "ref": "#pair" 144 - } 145 - } 146 - } 147 - }, 148 - "workflow": { 149 - "type": "object", 150 - "required": [ 151 - "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 156 - ], 157 - "properties": { 158 - "name": { 159 - "type": "string" 160 - }, 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 - } 181 - }, 182 - "clone": { 183 - "type": "ref", 184 - "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 196 - "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 - } 204 - } 205 - }, 206 - "cloneOpts": { 207 - "type": "object", 208 - "required": [ 209 - "skip", 210 - "depth", 211 - "submodules" 212 - ], 213 - "properties": { 214 - "skip": { 215 - "type": "boolean" 216 - }, 217 - "depth": { 218 - "type": "integer" 219 - }, 220 - "submodules": { 221 - "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 - } 245 - } 246 - }, 247 - "pair": { 248 - "type": "object", 249 - "required": [ 250 - "key", 251 - "value" 252 - ], 253 - "properties": { 254 - "key": { 255 - "type": "string" 256 - }, 257 - "value": { 258 - "type": "string" 259 - } 260 - } 261 - } 262 - } 263 - }
···
+37
lexicons/repo/addSecret.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/repo/artifact.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+36
lexicons/repo/collaborator.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+29
lexicons/repo/defaultBranch.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+67
lexicons/repo/listSecrets.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+31
lexicons/repo/removeSecret.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+54
lexicons/repo/repo.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "format": "datetime", 38 + "minGraphemes": 1, 39 + "maxGraphemes": 140 40 + }, 41 + "source": { 42 + "type": "string", 43 + "format": "uri", 44 + "description": "source of the repo" 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
···
+25
lexicons/spindle/spindle.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 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 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 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 - } 25 -
···
+514
nix/gomod2nix.toml
···
··· 1 + schema = 3 2 + 3 + [mod] 4 + [mod."dario.cat/mergo"] 5 + version = "v1.0.1" 6 + hash = "sha256-wcG6+x0k6KzOSlaPA+1RFxa06/RIAePJTAjjuhLbImw=" 7 + [mod."github.com/Blank-Xu/sql-adapter"] 8 + version = "v1.1.1" 9 + hash = "sha256-9AiQhXoNPCiViV+p5aa3qGFkYU4rJNbADvNdYGq4GA4=" 10 + [mod."github.com/Microsoft/go-winio"] 11 + version = "v0.6.2" 12 + hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 + [mod."github.com/ProtonMail/go-crypto"] 14 + version = "v1.3.0" 15 + hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/alecthomas/assert/v2"] 17 + version = "v2.11.0" 18 + hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" 19 + [mod."github.com/alecthomas/chroma/v2"] 20 + version = "v2.19.0" 21 + hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 22 + replaced = "github.com/oppiliappan/chroma/v2" 23 + [mod."github.com/alecthomas/repr"] 24 + version = "v0.4.0" 25 + hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU=" 26 + [mod."github.com/anmitsu/go-shlex"] 27 + version = "v0.0.0-20200514113438-38f4b401e2be" 28 + hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" 29 + [mod."github.com/avast/retry-go/v4"] 30 + version = "v4.6.1" 31 + hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 + [mod."github.com/aymerick/douceur"] 33 + version = "v0.2.0" 34 + hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" 35 + [mod."github.com/beorn7/perks"] 36 + version = "v1.0.1" 37 + hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 38 + [mod."github.com/bluekeyes/go-gitdiff"] 39 + version = "v0.8.2" 40 + hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 + replaced = "tangled.sh/oppi.li/go-gitdiff" 42 + [mod."github.com/bluesky-social/indigo"] 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 + [mod."github.com/bluesky-social/jetstream"] 46 + version = "v0.0.0-20241210005130-ea96859b93d1" 47 + hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 48 + [mod."github.com/bmatcuk/doublestar/v4"] 49 + version = "v4.7.1" 50 + hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 51 + [mod."github.com/carlmjohnson/versioninfo"] 52 + version = "v0.22.5" 53 + hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" 54 + [mod."github.com/casbin/casbin/v2"] 55 + version = "v2.103.0" 56 + hash = "sha256-adYds8Arni/ioPM9J0F+wAlJqhLLtCV9epv7d7tDvAQ=" 57 + [mod."github.com/casbin/govaluate"] 58 + version = "v1.3.0" 59 + hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 60 + [mod."github.com/cenkalti/backoff/v4"] 61 + version = "v4.3.0" 62 + hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8=" 63 + [mod."github.com/cespare/xxhash/v2"] 64 + version = "v2.3.0" 65 + hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 66 + [mod."github.com/cloudflare/circl"] 67 + version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 + hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/containerd/errdefs"] 70 + version = "v1.0.0" 71 + hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" 72 + [mod."github.com/containerd/errdefs/pkg"] 73 + version = "v0.3.0" 74 + hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg=" 75 + [mod."github.com/containerd/log"] 76 + version = "v0.1.0" 77 + hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s=" 78 + [mod."github.com/cyphar/filepath-securejoin"] 79 + version = "v0.4.1" 80 + hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM=" 81 + [mod."github.com/davecgh/go-spew"] 82 + version = "v1.1.2-0.20180830191138-d8f796af33cc" 83 + hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 84 + [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 85 + version = "v4.4.0" 86 + hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 87 + [mod."github.com/dgraph-io/ristretto"] 88 + version = "v0.2.0" 89 + hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" 90 + [mod."github.com/dgryski/go-rendezvous"] 91 + version = "v0.0.0-20200823014737-9f7001d12a5f" 92 + hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI=" 93 + [mod."github.com/distribution/reference"] 94 + version = "v0.6.0" 95 + hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4=" 96 + [mod."github.com/dlclark/regexp2"] 97 + version = "v1.11.5" 98 + hash = "sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ=" 99 + [mod."github.com/docker/docker"] 100 + version = "v28.2.2+incompatible" 101 + hash = "sha256-5FnlTcygdxpHyFB0/7EsYocFhADUAjC/Dku0Xn4W8so=" 102 + [mod."github.com/docker/go-connections"] 103 + version = "v0.5.0" 104 + hash = "sha256-aGbMRrguh98DupIHgcpLkVUZpwycx1noQXbtTl5Sbms=" 105 + [mod."github.com/docker/go-units"] 106 + version = "v0.5.0" 107 + hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE=" 108 + [mod."github.com/dustin/go-humanize"] 109 + version = "v1.0.1" 110 + hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 111 + [mod."github.com/emirpasic/gods"] 112 + version = "v1.18.1" 113 + hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" 114 + [mod."github.com/felixge/httpsnoop"] 115 + version = "v1.0.4" 116 + hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 117 + [mod."github.com/fsnotify/fsnotify"] 118 + version = "v1.6.0" 119 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 120 + [mod."github.com/gliderlabs/ssh"] 121 + version = "v0.3.8" 122 + hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" 123 + [mod."github.com/go-chi/chi/v5"] 124 + version = "v5.2.0" 125 + hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A=" 126 + [mod."github.com/go-enry/go-enry/v2"] 127 + version = "v2.9.2" 128 + hash = "sha256-LkCSW+4+DkTok1JcOQR0rt3UKNKVn4KPaiDeatdQhCU=" 129 + [mod."github.com/go-enry/go-oniguruma"] 130 + version = "v1.2.1" 131 + hash = "sha256-DoCNyX75CuCgFnfSZs63VB4+HAIMDBgwcQglXXHRj/I=" 132 + [mod."github.com/go-git/gcfg"] 133 + version = "v1.5.1-0.20230307220236-3a3c6141e376" 134 + hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" 135 + [mod."github.com/go-git/go-billy/v5"] 136 + version = "v5.6.2" 137 + hash = "sha256-VgbxcLkHjiSyRIfKS7E9Sn8OynCrMGUDkwFz6K2TVL4=" 138 + [mod."github.com/go-git/go-git/v5"] 139 + version = "v5.17.0" 140 + hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 141 + replaced = "github.com/oppiliappan/go-git/v5" 142 + [mod."github.com/go-jose/go-jose/v3"] 143 + version = "v3.0.4" 144 + hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 145 + [mod."github.com/go-logr/logr"] 146 + version = "v1.4.3" 147 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 148 + [mod."github.com/go-logr/stdr"] 149 + version = "v1.2.2" 150 + hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 151 + [mod."github.com/go-redis/cache/v9"] 152 + version = "v9.0.0" 153 + hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 154 + [mod."github.com/go-test/deep"] 155 + version = "v1.1.1" 156 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 157 + [mod."github.com/goccy/go-json"] 158 + version = "v0.10.5" 159 + hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" 160 + [mod."github.com/gogo/protobuf"] 161 + version = "v1.3.2" 162 + hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 163 + [mod."github.com/golang-jwt/jwt/v5"] 164 + version = "v5.2.3" 165 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 166 + [mod."github.com/golang/groupcache"] 167 + version = "v0.0.0-20241129210726-2c02b8208cf8" 168 + hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 169 + [mod."github.com/golang/mock"] 170 + version = "v1.6.0" 171 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 172 + [mod."github.com/google/uuid"] 173 + version = "v1.6.0" 174 + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 175 + [mod."github.com/gorilla/css"] 176 + version = "v1.0.1" 177 + hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 178 + [mod."github.com/gorilla/securecookie"] 179 + version = "v1.1.2" 180 + hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" 181 + [mod."github.com/gorilla/sessions"] 182 + version = "v1.4.0" 183 + hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 184 + [mod."github.com/gorilla/websocket"] 185 + version = "v1.5.4-0.20250319132907-e064f32e3674" 186 + hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to=" 187 + [mod."github.com/hashicorp/errwrap"] 188 + version = "v1.1.0" 189 + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" 190 + [mod."github.com/hashicorp/go-cleanhttp"] 191 + version = "v0.5.2" 192 + hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 193 + [mod."github.com/hashicorp/go-multierror"] 194 + version = "v1.1.1" 195 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 196 + [mod."github.com/hashicorp/go-retryablehttp"] 197 + version = "v0.7.8" 198 + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 199 + [mod."github.com/hashicorp/go-secure-stdlib/parseutil"] 200 + version = "v0.2.0" 201 + hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8=" 202 + [mod."github.com/hashicorp/go-secure-stdlib/strutil"] 203 + version = "v0.1.2" 204 + hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A=" 205 + [mod."github.com/hashicorp/go-sockaddr"] 206 + version = "v1.0.7" 207 + hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 208 + [mod."github.com/hashicorp/golang-lru"] 209 + version = "v1.0.2" 210 + hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 211 + [mod."github.com/hashicorp/golang-lru/v2"] 212 + version = "v2.0.7" 213 + hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 214 + [mod."github.com/hashicorp/hcl"] 215 + version = "v1.0.1-vault-7" 216 + hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM=" 217 + [mod."github.com/hexops/gotextdiff"] 218 + version = "v1.0.3" 219 + hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0=" 220 + [mod."github.com/hiddeco/sshsig"] 221 + version = "v0.2.0" 222 + hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" 223 + [mod."github.com/hpcloud/tail"] 224 + version = "v1.0.0" 225 + hash = "sha256-7ByBr/RcOwIsGPCiCUpfNwUSvU18QAY+HMnCJr8uU1w=" 226 + [mod."github.com/ipfs/bbloom"] 227 + version = "v0.0.4" 228 + hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 229 + [mod."github.com/ipfs/boxo"] 230 + version = "v0.33.0" 231 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 232 + [mod."github.com/ipfs/go-block-format"] 233 + version = "v0.2.2" 234 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 235 + [mod."github.com/ipfs/go-cid"] 236 + version = "v0.5.0" 237 + hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" 238 + [mod."github.com/ipfs/go-datastore"] 239 + version = "v0.8.2" 240 + hash = "sha256-9Q7+bi04srAE3AcXzWSGs/HP6DWnE1Edtx3NnjMQi8U=" 241 + [mod."github.com/ipfs/go-ipfs-blockstore"] 242 + version = "v1.3.1" 243 + hash = "sha256-NFlKr8bdJcM5FLlkc51sKt4AnMMlHS4wbdKiiaoDaqg=" 244 + [mod."github.com/ipfs/go-ipfs-ds-help"] 245 + version = "v1.1.1" 246 + hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 247 + [mod."github.com/ipfs/go-ipld-cbor"] 248 + version = "v0.2.1" 249 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 250 + [mod."github.com/ipfs/go-ipld-format"] 251 + version = "v0.6.2" 252 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 253 + [mod."github.com/ipfs/go-log"] 254 + version = "v1.0.5" 255 + hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" 256 + [mod."github.com/ipfs/go-log/v2"] 257 + version = "v2.6.0" 258 + hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk=" 259 + [mod."github.com/ipfs/go-metrics-interface"] 260 + version = "v0.3.0" 261 + hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 262 + [mod."github.com/kevinburke/ssh_config"] 263 + version = "v1.2.0" 264 + hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" 265 + [mod."github.com/klauspost/compress"] 266 + version = "v1.18.0" 267 + hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 268 + [mod."github.com/klauspost/cpuid/v2"] 269 + version = "v2.3.0" 270 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 271 + [mod."github.com/lestrrat-go/blackmagic"] 272 + version = "v1.0.4" 273 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 274 + [mod."github.com/lestrrat-go/httpcc"] 275 + version = "v1.0.1" 276 + hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 277 + [mod."github.com/lestrrat-go/httprc"] 278 + version = "v1.0.6" 279 + hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 280 + [mod."github.com/lestrrat-go/iter"] 281 + version = "v1.0.2" 282 + hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 283 + [mod."github.com/lestrrat-go/jwx/v2"] 284 + version = "v2.1.6" 285 + hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 286 + [mod."github.com/lestrrat-go/option"] 287 + version = "v1.0.1" 288 + hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 289 + [mod."github.com/mattn/go-isatty"] 290 + version = "v0.0.20" 291 + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 292 + [mod."github.com/mattn/go-sqlite3"] 293 + version = "v1.14.24" 294 + hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" 295 + [mod."github.com/microcosm-cc/bluemonday"] 296 + version = "v1.0.27" 297 + hash = "sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=" 298 + [mod."github.com/minio/sha256-simd"] 299 + version = "v1.0.1" 300 + hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 301 + [mod."github.com/mitchellh/mapstructure"] 302 + version = "v1.5.0" 303 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 304 + [mod."github.com/moby/docker-image-spec"] 305 + version = "v1.3.1" 306 + hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" 307 + [mod."github.com/moby/sys/atomicwriter"] 308 + version = "v0.1.0" 309 + hash = "sha256-i46GNrsICnJ0AYkN+ocbVZ2GNTQVEsrVX5WcjKzjtBM=" 310 + [mod."github.com/moby/term"] 311 + version = "v0.5.2" 312 + hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 313 + [mod."github.com/morikuni/aec"] 314 + version = "v1.0.0" 315 + hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 316 + [mod."github.com/mr-tron/base58"] 317 + version = "v1.2.0" 318 + hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 319 + [mod."github.com/multiformats/go-base32"] 320 + version = "v0.1.0" 321 + hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" 322 + [mod."github.com/multiformats/go-base36"] 323 + version = "v0.2.0" 324 + hash = "sha256-GKNnAGA0Lb39BDGYBm1ieKdXmho8Pu7ouyfVPXvV0PE=" 325 + [mod."github.com/multiformats/go-multibase"] 326 + version = "v0.2.0" 327 + hash = "sha256-w+hp6u5bWyd34qe0CX+bq487ADqq6SgRR/JuqRB578s=" 328 + [mod."github.com/multiformats/go-multihash"] 329 + version = "v0.2.3" 330 + hash = "sha256-zqIIE5jMFzm+qhUrouSF+WdXGeHUEYIQvVnKWWU6mRs=" 331 + [mod."github.com/multiformats/go-varint"] 332 + version = "v0.0.7" 333 + hash = "sha256-To3Uuv7uSUJEr5OTwxE1LEIpA62xY3M/KKMNlscHmlA=" 334 + [mod."github.com/munnerz/goautoneg"] 335 + version = "v0.0.0-20191010083416-a7dc8b61c822" 336 + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 337 + [mod."github.com/onsi/gomega"] 338 + version = "v1.37.0" 339 + hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o=" 340 + [mod."github.com/openbao/openbao/api/v2"] 341 + version = "v2.3.0" 342 + hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM=" 343 + [mod."github.com/opencontainers/go-digest"] 344 + version = "v1.0.0" 345 + hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" 346 + [mod."github.com/opencontainers/image-spec"] 347 + version = "v1.1.1" 348 + hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 349 + [mod."github.com/opentracing/opentracing-go"] 350 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 351 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 352 + [mod."github.com/pjbgf/sha1cd"] 353 + version = "v0.3.2" 354 + hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" 355 + [mod."github.com/pkg/errors"] 356 + version = "v0.9.1" 357 + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" 358 + [mod."github.com/pmezard/go-difflib"] 359 + version = "v1.0.1-0.20181226105442-5d4384ee4fb2" 360 + hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=" 361 + [mod."github.com/polydawn/refmt"] 362 + version = "v0.89.1-0.20221221234430-40501e09de1f" 363 + hash = "sha256-wBdFROClTHNPYU4IjeKbBXaG7F6j5hZe15gMxiqKvi4=" 364 + [mod."github.com/posthog/posthog-go"] 365 + version = "v1.5.5" 366 + hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8=" 367 + [mod."github.com/prometheus/client_golang"] 368 + version = "v1.22.0" 369 + hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ=" 370 + [mod."github.com/prometheus/client_model"] 371 + version = "v0.6.2" 372 + hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 373 + [mod."github.com/prometheus/common"] 374 + version = "v0.64.0" 375 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 376 + [mod."github.com/prometheus/procfs"] 377 + version = "v0.16.1" 378 + hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 379 + [mod."github.com/redis/go-redis/v9"] 380 + version = "v9.7.3" 381 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 382 + [mod."github.com/resend/resend-go/v2"] 383 + version = "v2.15.0" 384 + hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 385 + [mod."github.com/ryanuber/go-glob"] 386 + version = "v1.0.0" 387 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 388 + [mod."github.com/segmentio/asm"] 389 + version = "v1.2.0" 390 + hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 391 + [mod."github.com/sergi/go-diff"] 392 + version = "v1.1.0" 393 + hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" 394 + replaced = "github.com/sergi/go-diff" 395 + [mod."github.com/sethvargo/go-envconfig"] 396 + version = "v1.1.0" 397 + hash = "sha256-WelRHfyZG9hrA4fbQcfBawb2ZXBQNT1ourEYHzQdZ4w=" 398 + [mod."github.com/spaolacci/murmur3"] 399 + version = "v1.1.0" 400 + hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 401 + [mod."github.com/stretchr/testify"] 402 + version = "v1.10.0" 403 + hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 404 + [mod."github.com/urfave/cli/v3"] 405 + version = "v3.3.3" 406 + hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 407 + [mod."github.com/vmihailenco/go-tinylfu"] 408 + version = "v0.2.2" 409 + hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" 410 + [mod."github.com/vmihailenco/msgpack/v5"] 411 + version = "v5.4.1" 412 + hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk=" 413 + [mod."github.com/vmihailenco/tagparser/v2"] 414 + version = "v2.0.0" 415 + hash = "sha256-M9QyaKhSmmYwsJk7gkjtqu9PuiqZHSmTkous8VWkWY0=" 416 + [mod."github.com/whyrusleeping/cbor-gen"] 417 + version = "v0.3.1" 418 + hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 419 + [mod."github.com/yuin/goldmark"] 420 + version = "v1.4.13" 421 + hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI=" 422 + [mod."gitlab.com/yawning/secp256k1-voi"] 423 + version = "v0.0.0-20230925100816-f2616030848b" 424 + hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 425 + [mod."gitlab.com/yawning/tuplehash"] 426 + version = "v0.0.0-20230713102510-df83abbf9a02" 427 + hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 428 + [mod."go.opentelemetry.io/auto/sdk"] 429 + version = "v1.1.0" 430 + hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 431 + [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 432 + version = "v0.62.0" 433 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 434 + [mod."go.opentelemetry.io/otel"] 435 + version = "v1.37.0" 436 + hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 437 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 438 + version = "v1.33.0" 439 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 440 + [mod."go.opentelemetry.io/otel/metric"] 441 + version = "v1.37.0" 442 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 443 + [mod."go.opentelemetry.io/otel/trace"] 444 + version = "v1.37.0" 445 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 446 + [mod."go.opentelemetry.io/proto/otlp"] 447 + version = "v1.6.0" 448 + hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" 449 + [mod."go.uber.org/atomic"] 450 + version = "v1.11.0" 451 + hash = "sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY=" 452 + [mod."go.uber.org/multierr"] 453 + version = "v1.11.0" 454 + hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" 455 + [mod."go.uber.org/zap"] 456 + version = "v1.27.0" 457 + hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 458 + [mod."golang.org/x/crypto"] 459 + version = "v0.40.0" 460 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 461 + [mod."golang.org/x/exp"] 462 + version = "v0.0.0-20250620022241-b7579e27df2b" 463 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 464 + [mod."golang.org/x/net"] 465 + version = "v0.42.0" 466 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 467 + [mod."golang.org/x/sync"] 468 + version = "v0.16.0" 469 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 470 + [mod."golang.org/x/sys"] 471 + version = "v0.34.0" 472 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 473 + [mod."golang.org/x/text"] 474 + version = "v0.27.0" 475 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 476 + [mod."golang.org/x/time"] 477 + version = "v0.12.0" 478 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 479 + [mod."golang.org/x/xerrors"] 480 + version = "v0.0.0-20240903120638-7835f813f4da" 481 + hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 482 + [mod."google.golang.org/genproto/googleapis/api"] 483 + version = "v0.0.0-20250603155806-513f23925822" 484 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 485 + [mod."google.golang.org/genproto/googleapis/rpc"] 486 + version = "v0.0.0-20250603155806-513f23925822" 487 + hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 488 + [mod."google.golang.org/grpc"] 489 + version = "v1.73.0" 490 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 491 + [mod."google.golang.org/protobuf"] 492 + version = "v1.36.6" 493 + hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" 494 + [mod."gopkg.in/fsnotify.v1"] 495 + version = "v1.4.7" 496 + hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8=" 497 + [mod."gopkg.in/tomb.v1"] 498 + version = "v1.0.0-20141024135613-dd632973f1e7" 499 + hash = "sha256-W/4wBAvuaBFHhowB67SZZfXCRDp5tzbYG4vo81TAFdM=" 500 + [mod."gopkg.in/warnings.v0"] 501 + version = "v0.1.2" 502 + hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8=" 503 + [mod."gopkg.in/yaml.v3"] 504 + version = "v3.0.1" 505 + hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" 506 + [mod."gotest.tools/v3"] 507 + version = "v3.5.2" 508 + hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE=" 509 + [mod."lukechampine.com/blake3"] 510 + version = "v1.4.1" 511 + hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 512 + [mod."tangled.sh/icyphox.sh/atproto-oauth"] 513 + version = "v0.0.0-20250724194903-28e660378cb1" 514 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+40 -35
nix/modules/appview.nix
··· 1 - {self}: { 2 config, 3 - pkgs, 4 lib, 5 ... 6 - }: 7 - with lib; { 8 - options = { 9 - services.tangled-appview = { 10 - enable = mkOption { 11 - type = types.bool; 12 - default = false; 13 - description = "Enable tangled appview"; 14 - }; 15 - port = mkOption { 16 - type = types.int; 17 - default = 3000; 18 - description = "Port to run the appview on"; 19 - }; 20 - cookie_secret = mkOption { 21 - type = types.str; 22 - default = "00000000000000000000000000000000"; 23 - description = "Cookie secret"; 24 }; 25 }; 26 - }; 27 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 32 33 - serviceConfig = { 34 - ListenStream = "0.0.0.0:${toString config.services.tangled-appview.port}"; 35 - ExecStart = "${self.packages.${pkgs.system}.appview}/bin/appview"; 36 - Restart = "always"; 37 - }; 38 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 42 }; 43 }; 44 - }; 45 - }
··· 1 + { 2 config, 3 lib, 4 ... 5 + }: let 6 + cfg = config.services.tangled-appview; 7 + in 8 + with lib; { 9 + options = { 10 + services.tangled-appview = { 11 + enable = mkOption { 12 + type = types.bool; 13 + default = false; 14 + description = "Enable tangled appview"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the appview"; 19 + }; 20 + port = mkOption { 21 + type = types.int; 22 + default = 3000; 23 + description = "Port to run the appview on"; 24 + }; 25 + cookie_secret = mkOption { 26 + type = types.str; 27 + default = "00000000000000000000000000000000"; 28 + description = "Cookie secret"; 29 + }; 30 }; 31 }; 32 33 + config = mkIf cfg.enable { 34 + systemd.services.tangled-appview = { 35 + description = "tangled appview service"; 36 + wantedBy = ["multi-user.target"]; 37 38 + serviceConfig = { 39 + ListenStream = "0.0.0.0:${toString cfg.port}"; 40 + ExecStart = "${cfg.package}/bin/appview"; 41 + Restart = "always"; 42 + }; 43 44 + environment = { 45 + TANGLED_DB_PATH = "appview.db"; 46 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 47 + }; 48 }; 49 }; 50 + }
+45 -7
nix/modules/knot.nix
··· 1 - {self}: { 2 config, 3 pkgs, 4 lib, ··· 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled knot"; 16 }; 17 18 appviewEndpoint = mkOption { ··· 53 }; 54 }; 55 56 server = { 57 listenAddr = mkOption { 58 type = types.str; ··· 94 }; 95 96 config = mkIf cfg.enable { 97 - environment.systemPackages = with pkgs; [ 98 - git 99 - self.packages."${pkgs.system}".knot 100 ]; 101 102 - system.activationScripts.gitConfig = '' 103 mkdir -p "${cfg.repo.scanPath}" 104 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 105 ··· 108 [user] 109 name = Git User 110 email = git@example.com 111 EOF 112 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 113 ''; 114 ··· 135 mode = "0555"; 136 text = '' 137 #!${pkgs.stdenv.shell} 138 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 139 -output authorized-keys \ 140 -internal-api "http://${cfg.server.internalListenAddr}" \ 141 -git-dir "${cfg.repo.scanPath}" \ ··· 160 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 161 ]; 162 EnvironmentFile = cfg.server.secretFile; 163 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 164 Restart = "always"; 165 }; 166 };
··· 1 + { 2 config, 3 pkgs, 4 lib, ··· 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled knot"; 16 + }; 17 + 18 + package = mkOption { 19 + type = types.package; 20 + description = "Package to use for the knot"; 21 }; 22 23 appviewEndpoint = mkOption { ··· 58 }; 59 }; 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 83 server = { 84 listenAddr = mkOption { 85 type = types.str; ··· 121 }; 122 123 config = mkIf cfg.enable { 124 + environment.systemPackages = [ 125 + pkgs.git 126 + cfg.package 127 ]; 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 ··· 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 ··· 173 mode = "0555"; 174 text = '' 175 #!${pkgs.stdenv.shell} 176 + ${cfg.package}/bin/knot keys \ 177 -output authorized-keys \ 178 -internal-api "http://${cfg.server.internalListenAddr}" \ 179 -git-dir "${cfg.repo.scanPath}" \ ··· 198 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 199 ]; 200 EnvironmentFile = cfg.server.secretFile; 201 + ExecStart = "${cfg.package}/bin/knot server"; 202 Restart = "always"; 203 }; 204 };
+8 -5
nix/modules/spindle.nix
··· 1 - {self}: { 2 config, 3 - pkgs, 4 lib, 5 ... 6 }: let ··· 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled spindle"; 16 }; 17 18 server = { ··· 60 description = "Nixery instance to use"; 61 }; 62 63 - stepTimeout = mkOption { 64 type = types.str; 65 default = "5m"; 66 description = "Timeout for each step of a pipeline"; ··· 87 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 88 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 89 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 - "SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}" 91 ]; 92 - ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 93 Restart = "always"; 94 }; 95 };
··· 1 + { 2 config, 3 lib, 4 ... 5 }: let ··· 12 type = types.bool; 13 default = false; 14 description = "Enable a tangled spindle"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the spindle"; 19 }; 20 21 server = { ··· 63 description = "Nixery instance to use"; 64 }; 65 66 + workflowTimeout = mkOption { 67 type = types.str; 68 default = "5m"; 69 description = "Timeout for each step of a pipeline"; ··· 90 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 92 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 + "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 ]; 95 + ExecStart = "${cfg.package}/bin/spindle"; 96 Restart = "always"; 97 }; 98 };
+6 -9
nix/pkgs/appview.nix
··· 1 { 2 - buildGoModule, 3 - stdenv, 4 htmx-src, 5 htmx-ws-src, 6 lucide-src, ··· 8 ibm-plex-mono-src, 9 tailwindcss, 10 sqlite-lib, 11 - goModHash, 12 gitignoreSource, 13 }: 14 - buildGoModule { 15 - inherit stdenv; 16 - 17 pname = "appview"; 18 version = "0.1.0"; 19 src = gitignoreSource ../..; 20 21 postUnpack = '' 22 pushd source ··· 33 34 doCheck = false; 35 subPackages = ["cmd/appview"]; 36 - vendorHash = goModHash; 37 38 - tags = "libsqlite3"; 39 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 40 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 41 - env.CGO_ENABLED = 1; 42 }
··· 1 { 2 + buildGoApplication, 3 + modules, 4 htmx-src, 5 htmx-ws-src, 6 lucide-src, ··· 8 ibm-plex-mono-src, 9 tailwindcss, 10 sqlite-lib, 11 gitignoreSource, 12 }: 13 + buildGoApplication { 14 pname = "appview"; 15 version = "0.1.0"; 16 src = gitignoreSource ../..; 17 + inherit modules; 18 19 postUnpack = '' 20 pushd source ··· 31 32 doCheck = false; 33 subPackages = ["cmd/appview"]; 34 35 + tags = ["libsqlite3"]; 36 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 37 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 38 + CGO_ENABLED = 1; 39 }
+5 -5
nix/pkgs/genjwks.nix
··· 1 { 2 - buildGoModule, 3 - goModHash, 4 gitignoreSource, 5 }: 6 - buildGoModule { 7 pname = "genjwks"; 8 version = "0.1.0"; 9 src = gitignoreSource ../..; 10 subPackages = ["cmd/genjwks"]; 11 - vendorHash = goModHash; 12 doCheck = false; 13 - env.CGO_ENABLED = 0; 14 }
··· 1 { 2 gitignoreSource, 3 + buildGoApplication, 4 + modules, 5 }: 6 + buildGoApplication { 7 pname = "genjwks"; 8 version = "0.1.0"; 9 src = gitignoreSource ../..; 10 + inherit modules; 11 subPackages = ["cmd/genjwks"]; 12 doCheck = false; 13 + CGO_ENABLED = 0; 14 }
+6 -7
nix/pkgs/knot-unwrapped.nix
··· 1 { 2 - buildGoModule, 3 - stdenv, 4 sqlite-lib, 5 - goModHash, 6 gitignoreSource, 7 }: 8 - buildGoModule { 9 pname = "knot"; 10 version = "0.1.0"; 11 src = gitignoreSource ../..; 12 13 doCheck = false; 14 15 subPackages = ["cmd/knot"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 18 19 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 22 }
··· 1 { 2 + buildGoApplication, 3 + modules, 4 sqlite-lib, 5 gitignoreSource, 6 }: 7 + buildGoApplication { 8 pname = "knot"; 9 version = "0.1.0"; 10 src = gitignoreSource ../..; 11 + inherit modules; 12 13 doCheck = false; 14 15 subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 17 18 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 19 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 20 + CGO_ENABLED = 1; 21 }
+6 -7
nix/pkgs/spindle.nix
··· 1 { 2 - buildGoModule, 3 - stdenv, 4 sqlite-lib, 5 - goModHash, 6 gitignoreSource, 7 }: 8 - buildGoModule { 9 pname = "spindle"; 10 version = "0.1.0"; 11 src = gitignoreSource ../..; 12 13 doCheck = false; 14 15 subPackages = ["cmd/spindle"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 18 19 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 22 }
··· 1 { 2 + buildGoApplication, 3 + modules, 4 sqlite-lib, 5 gitignoreSource, 6 }: 7 + buildGoApplication { 8 pname = "spindle"; 9 version = "0.1.0"; 10 src = gitignoreSource ../..; 11 + inherit modules; 12 13 doCheck = false; 14 15 subPackages = ["cmd/spindle"]; 16 + tags = ["libsqlite3"]; 17 18 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 19 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 20 + CGO_ENABLED = 1; 21 }
+1
nix/vm.nix
··· 48 ]; 49 services.tangled-knot = { 50 enable = true; 51 server = { 52 secretFile = "/var/lib/knot/secret"; 53 hostname = "localhost:6000";
··· 48 ]; 49 services.tangled-knot = { 50 enable = true; 51 + motd = "Welcome to the development knot!\n"; 52 server = { 53 secretFile = "/var/lib/knot/secret"; 54 hostname = "localhost:6000";
+25
patchutil/interdiff.go
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 ) 9 10 type InterdiffResult struct { ··· 33 *gitdiff.File 34 Name string 35 Status InterdiffFileStatus 36 } 37 38 func (s *InterdiffFile) String() string {
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/types" 9 ) 10 11 type InterdiffResult struct { ··· 34 *gitdiff.File 35 Name string 36 Status InterdiffFileStatus 37 + } 38 + 39 + func (s *InterdiffFile) Split() *types.SplitDiff { 40 + fragments := make([]types.SplitFragment, len(s.TextFragments)) 41 + 42 + for i, fragment := range s.TextFragments { 43 + leftLines, rightLines := types.SeparateLines(fragment) 44 + 45 + fragments[i] = types.SplitFragment{ 46 + Header: fragment.Header(), 47 + LeftLines: leftLines, 48 + RightLines: rightLines, 49 + } 50 + } 51 + 52 + return &types.SplitDiff{ 53 + Name: s.Id(), 54 + TextFragments: fragments, 55 + } 56 + } 57 + 58 + // used by html elements as a unique ID for hrefs 59 + func (s *InterdiffFile) Id() string { 60 + return s.Name 61 } 62 63 func (s *InterdiffFile) String() string {
+4
rbac/rbac.go
··· 11 ) 12 13 const ( 14 Model = ` 15 [request_definition] 16 r = sub, dom, obj, act
··· 11 ) 12 13 const ( 14 + ThisServer = "thisserver" // resource identifier for local rbac enforcement 15 + ) 16 + 17 + const ( 18 Model = ` 19 [request_definition] 20 r = sub, dom, obj, act
+23 -6
spindle/config/config.go
··· 2 3 import ( 4 "context" 5 6 "github.com/sethvargo/go-envconfig" 7 ) 8 9 type Server struct { 10 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 11 - DBPath string `env:"DB_PATH, default=spindle.db"` 12 - Hostname string `env:"HOSTNAME, required"` 13 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 14 - Dev bool `env:"DEV, default=false"` 15 - Owner string `env:"OWNER, required"` 16 } 17 18 type Pipelines struct {
··· 2 3 import ( 4 "context" 5 + "fmt" 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/sethvargo/go-envconfig" 9 ) 10 11 type Server struct { 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 + DBPath string `env:"DB_PATH, default=spindle.db"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + Dev bool `env:"DEV, default=false"` 17 + Owner string `env:"OWNER, required"` 18 + Secrets Secrets `env:",prefix=SECRETS_"` 19 + } 20 + 21 + func (s Server) Did() syntax.DID { 22 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 23 + } 24 + 25 + type Secrets struct { 26 + Provider string `env:"PROVIDER, default=sqlite"` 27 + OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` 28 + } 29 + 30 + type OpenBaoConfig struct { 31 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 32 + Mount string `env:"MOUNT, default=spindle"` 33 } 34 35 type Pipelines struct {
+42 -19
spindle/engine/engine.go
··· 11 "sync" 12 "time" 13 14 "github.com/docker/docker/api/types/container" 15 "github.com/docker/docker/api/types/image" 16 "github.com/docker/docker/api/types/mount" ··· 18 "github.com/docker/docker/api/types/volume" 19 "github.com/docker/docker/client" 20 "github.com/docker/docker/pkg/stdcopy" 21 "tangled.sh/tangled.sh/core/log" 22 "tangled.sh/tangled.sh/core/notifier" 23 "tangled.sh/tangled.sh/core/spindle/config" 24 "tangled.sh/tangled.sh/core/spindle/db" 25 "tangled.sh/tangled.sh/core/spindle/models" 26 ) 27 28 const ( ··· 37 db *db.DB 38 n *notifier.Notifier 39 cfg *config.Config 40 41 cleanupMu sync.Mutex 42 cleanup map[string][]cleanupFunc 43 } 44 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 46 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 if err != nil { 48 return nil, err ··· 56 db: db, 57 n: n, 58 cfg: cfg, 59 } 60 61 e.cleanup = make(map[string][]cleanupFunc) ··· 66 func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 69 - wg := sync.WaitGroup{} 70 for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 74 wid := models.WorkflowId{ 75 PipelineId: pipelineId, 76 Name: w.Name, ··· 102 defer reader.Close() 103 io.Copy(os.Stdout, reader) 104 105 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 106 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 107 - if err != nil { 108 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 109 - workflowTimeout = 5 * time.Minute 110 - } 111 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 112 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 defer cancel() 114 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 116 if err != nil { 117 if errors.Is(err, ErrTimedOut) { 118 dbErr := e.db.StatusTimeout(wid, e.n) ··· 135 } 136 137 return nil 138 - }() 139 } 140 141 - wg.Wait() 142 } 143 144 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 186 // ONLY marks pipeline as failed if container's exit code is non-zero. 187 // All other errors are bubbled up. 188 // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 190 191 - for stepIdx, step := range steps { 192 select { 193 case <-ctx.Done(): 194 return ctx.Err() 195 default: 196 } 197 198 - envs := ConstructEnvs(step.Environment) 199 envs.AddEnv("HOME", workspaceDir) 200 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 202 hostConfig := hostConfig(wid) 203 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 205 Cmd: []string{"bash", "-c", step.Command}, 206 WorkingDir: workspaceDir, 207 Tty: false,
··· 11 "sync" 12 "time" 13 14 + 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" ··· 19 "github.com/docker/docker/api/types/volume" 20 "github.com/docker/docker/client" 21 "github.com/docker/docker/pkg/stdcopy" 22 + "golang.org/x/sync/errgroup" 23 "tangled.sh/tangled.sh/core/log" 24 "tangled.sh/tangled.sh/core/notifier" 25 "tangled.sh/tangled.sh/core/spindle/config" 26 "tangled.sh/tangled.sh/core/spindle/db" 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/secrets" 29 ) 30 31 const ( ··· 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 ··· 60 db: db, 61 n: n, 62 cfg: cfg, 63 + vault: vault, 64 } 65 66 e.cleanup = make(map[string][]cleanupFunc) ··· 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) 73 74 + // extract secrets 75 + var allSecrets []secrets.UnlockedSecret 76 + if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 + if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 + allSecrets = res 79 + } 80 + } 81 + 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 + 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, ··· 121 defer reader.Close() 122 io.Copy(os.Stdout, reader) 123 124 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 defer cancel() 126 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) ··· 147 } 148 149 return nil 150 + }) 151 } 152 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 } 159 160 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 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,
+129 -2
spindle/ingester.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 8 "tangled.sh/tangled.sh/core/api/tangled" 9 "tangled.sh/tangled.sh/core/eventconsumer" 10 11 "github.com/bluesky-social/jetstream/pkg/models" 12 ) 13 14 type Ingester func(ctx context.Context, e *models.Event) error ··· 33 s.ingestMember(ctx, e) 34 case tangled.RepoNSID: 35 s.ingestRepo(ctx, e) 36 } 37 38 return err ··· 72 return fmt.Errorf("failed to enforce permissions: %w", err) 73 } 74 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 76 l.Error("failed to add member", "error", err) 77 return fmt.Errorf("failed to add member: %w", err) 78 } ··· 90 return nil 91 } 92 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 94 var err error 95 96 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 ··· 127 return fmt.Errorf("failed to add repo: %w", err) 128 } 129 130 // add this knot to the event consumer 131 src := eventconsumer.NewKnotSource(record.Knot) 132 s.ks.AddSource(context.Background(), src) ··· 136 } 137 return nil 138 }
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 9 "tangled.sh/tangled.sh/core/api/tangled" 10 "tangled.sh/tangled.sh/core/eventconsumer" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/rbac" 13 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/bluesky-social/indigo/xrpc" 18 "github.com/bluesky-social/jetstream/pkg/models" 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 ) 21 22 type Ingester func(ctx context.Context, e *models.Event) error ··· 41 s.ingestMember(ctx, e) 42 case tangled.RepoNSID: 43 s.ingestRepo(ctx, e) 44 + case tangled.RepoCollaboratorNSID: 45 + s.ingestCollaborator(ctx, e) 46 } 47 48 return err ··· 82 return fmt.Errorf("failed to enforce permissions: %w", err) 83 } 84 85 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 86 l.Error("failed to add member", "error", err) 87 return fmt.Errorf("failed to add member: %w", err) 88 } ··· 100 return nil 101 } 102 103 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 104 var err error 105 + did := e.Did 106 + resolver := idresolver.DefaultResolver() 107 108 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 109 ··· 139 return fmt.Errorf("failed to add repo: %w", err) 140 } 141 142 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 143 + if err != nil { 144 + return err 145 + } 146 + 147 + // add repo to rbac 148 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 149 + l.Error("failed to add repo to enforcer", "error", err) 150 + return fmt.Errorf("failed to add repo: %w", err) 151 + } 152 + 153 + // add collaborators to rbac 154 + owner, err := resolver.ResolveIdent(ctx, did) 155 + if err != nil || owner.Handle.IsInvalidHandle() { 156 + return err 157 + } 158 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 159 + return err 160 + } 161 + 162 // add this knot to the event consumer 163 src := eventconsumer.NewKnotSource(record.Knot) 164 s.ks.AddSource(context.Background(), src) ··· 168 } 169 return nil 170 } 171 + 172 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 173 + var err error 174 + 175 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 176 + 177 + l.Info("ingesting collaborator record") 178 + 179 + switch e.Commit.Operation { 180 + case models.CommitOperationCreate, models.CommitOperationUpdate: 181 + raw := e.Commit.Record 182 + record := tangled.RepoCollaborator{} 183 + err = json.Unmarshal(raw, &record) 184 + if err != nil { 185 + l.Error("invalid record", "error", err) 186 + return err 187 + } 188 + 189 + resolver := idresolver.DefaultResolver() 190 + 191 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 192 + if err != nil || subjectId.Handle.IsInvalidHandle() { 193 + return err 194 + } 195 + 196 + repoAt, err := syntax.ParseATURI(record.Repo) 197 + if err != nil { 198 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 199 + return nil 200 + } 201 + 202 + // TODO: get rid of this entirely 203 + // resolve this aturi to extract the repo record 204 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 205 + if err != nil || owner.Handle.IsInvalidHandle() { 206 + return fmt.Errorf("failed to resolve handle: %w", err) 207 + } 208 + 209 + xrpcc := xrpc.Client{ 210 + Host: owner.PDSEndpoint(), 211 + } 212 + 213 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 214 + if err != nil { 215 + return err 216 + } 217 + 218 + repo := resp.Value.Val.(*tangled.Repo) 219 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 220 + 221 + // check perms for this user 222 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 223 + return fmt.Errorf("insufficient permissions: %w", err) 224 + } 225 + 226 + // add collaborator to rbac 227 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 228 + l.Error("failed to add repo to enforcer", "error", err) 229 + return fmt.Errorf("failed to add repo: %w", err) 230 + } 231 + 232 + return nil 233 + } 234 + return nil 235 + } 236 + 237 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 238 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 239 + 240 + l.Info("fetching and adding existing collaborators") 241 + 242 + xrpcc := xrpc.Client{ 243 + Host: owner.PDSEndpoint(), 244 + } 245 + 246 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 247 + if err != nil { 248 + return err 249 + } 250 + 251 + var errs error 252 + for _, r := range resp.Records { 253 + if r == nil { 254 + continue 255 + } 256 + record := r.Value.Val.(*tangled.RepoCollaborator) 257 + 258 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 259 + l.Error("failed to add repo to enforcer", "error", err) 260 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 261 + } 262 + } 263 + 264 + return errs 265 + }
+9 -12
spindle/models/pipeline.go
··· 8 ) 9 10 type Pipeline struct { 11 Workflows []Workflow 12 } 13 ··· 63 swf.Environment = workflowEnvToMap(twf.Environment) 64 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 setup := &setupSteps{} 69 70 setup.addStep(nixConfStep()) ··· 79 80 workflows = append(workflows, *swf) 81 } 82 - return &Pipeline{Workflows: workflows} 83 } 84 85 func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { ··· 115 116 return path.Join(nixery, dependencies) 117 } 118 - 119 - func (wf *Workflow) addNixProfileToPath() { 120 - wf.Environment["PATH"] = "$PATH:/.nix-profile/bin" 121 - } 122 - 123 - func (wf *Workflow) setGlobalEnvs() { 124 - wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes" 125 - wf.Environment["HOME"] = "/tangled/workspace" 126 - }
··· 8 ) 9 10 type Pipeline struct { 11 + RepoOwner string 12 + RepoName string 13 Workflows []Workflow 14 } 15 ··· 65 swf.Environment = workflowEnvToMap(twf.Environment) 66 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 68 setup := &setupSteps{} 69 70 setup.addStep(nixConfStep()) ··· 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 { ··· 121 122 return path.Join(nixery, dependencies) 123 }
+3
spindle/models/setup_steps.go
··· 102 continue 103 } 104 105 // collect packages from custom registries 106 for _, pkg := range packages { 107 customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
··· 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))
+25
spindle/motd
···
··· 1 + **** 2 + *** *** 3 + *** ** ****** ** 4 + ** * ***** 5 + * ** ** 6 + * * * *************** 7 + ** ** *# ** 8 + * ** ** *** ** 9 + * * ** ** * ****** 10 + * ** ** * ** * * 11 + ** ** *** ** ** * 12 + ** ** * ** * * 13 + ** **** ** * * 14 + ** *** ** ** ** 15 + *** ** ***** 16 + ******************** 17 + ** 18 + * 19 + #************** 20 + ** 21 + ******** 22 + 23 + This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 + 25 + Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
···
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "regexp" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + type DidSlashRepo string 13 + 14 + type Secret[T any] struct { 15 + Key string 16 + Value T 17 + Repo DidSlashRepo 18 + CreatedAt time.Time 19 + CreatedBy syntax.DID 20 + } 21 + 22 + // the secret is not present 23 + type LockedSecret = Secret[struct{}] 24 + 25 + // the secret is present in plaintext, never expose this publicly, 26 + // only use in the workflow engine 27 + type UnlockedSecret = Secret[string] 28 + 29 + type Manager interface { 30 + AddSecret(ctx context.Context, secret UnlockedSecret) error 31 + RemoveSecret(ctx context.Context, secret Secret[any]) error 32 + GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) 33 + GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) 34 + } 35 + 36 + // stopper interface for managers that need cleanup 37 + type Stopper interface { 38 + Stop() 39 + } 40 + 41 + var ErrKeyAlreadyPresent = errors.New("key already present") 42 + var ErrInvalidKeyIdent = errors.New("key is not a valid identifier") 43 + var ErrKeyNotFound = errors.New("key not found") 44 + 45 + // ensure that we are satisfying the interface 46 + var ( 47 + _ = []Manager{ 48 + &SqliteManager{}, 49 + &OpenBaoManager{}, 50 + } 51 + ) 52 + 53 + var ( 54 + // bash identifier syntax 55 + keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) 56 + ) 57 + 58 + func isValidKey(key string) bool { 59 + if key == "" { 60 + return false 61 + } 62 + return keyIdent.MatchString(key) 63 + } 64 + 65 + func ValidateKey(key string) error { 66 + if !isValidKey(key) { 67 + return ErrInvalidKeyIdent 68 + } 69 + return nil 70 + }
+313
spindle/secrets/openbao.go
···
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "path" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + vault "github.com/openbao/openbao/api/v2" 13 + ) 14 + 15 + type OpenBaoManager struct { 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + } 20 + 21 + type OpenBaoManagerOpt func(*OpenBaoManager) 22 + 23 + func WithMountPath(mountPath string) OpenBaoManagerOpt { 24 + return func(v *OpenBaoManager) { 25 + v.mountPath = mountPath 26 + } 27 + } 28 + 29 + // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 + // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 + // The proxy handles all authentication automatically via Auto-Auth 32 + func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 + if proxyAddress == "" { 34 + return nil, fmt.Errorf("proxy address cannot be empty") 35 + } 36 + 37 + config := vault.DefaultConfig() 38 + config.Address = proxyAddress 39 + 40 + client, err := vault.NewClient(config) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create openbao client: %w", err) 43 + } 44 + 45 + manager := &OpenBaoManager{ 46 + client: client, 47 + mountPath: "spindle", // default KV v2 mount path 48 + logger: logger, 49 + } 50 + 51 + for _, opt := range opts { 52 + opt(manager) 53 + } 54 + 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 57 + } 58 + 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 61 + } 62 + 63 + // testConnection verifies that we can connect to the proxy 64 + func (v *OpenBaoManager) testConnection() error { 65 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 + defer cancel() 67 + 68 + // try token self-lookup as a quick way to verify proxy works 69 + // and is authenticated 70 + _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 71 + if err != nil { 72 + return fmt.Errorf("proxy connection test failed: %w", err) 73 + } 74 + 75 + return nil 76 + } 77 + 78 + func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 79 + if err := ValidateKey(secret.Key); err != nil { 80 + return err 81 + } 82 + 83 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 85 + 86 + // Check if secret already exists 87 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 88 + if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 90 + return ErrKeyAlreadyPresent 91 + } 92 + 93 + secretData := map[string]interface{}{ 94 + "value": secret.Value, 95 + "repo": string(secret.Repo), 96 + "key": secret.Key, 97 + "created_at": secret.CreatedAt.Format(time.RFC3339), 98 + "created_by": secret.CreatedBy.String(), 99 + } 100 + 101 + v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 + resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 103 + if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 105 + return fmt.Errorf("failed to store secret in openbao: %w", err) 106 + } 107 + 108 + v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 + 110 + v.logger.Debug("verifying secret was written", "path", secretPath) 111 + readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 + if err != nil { 113 + v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 + return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 + } 116 + 117 + if readBack == nil || readBack.Data == nil { 118 + v.logger.Error("secret verification returned empty data", "path", secretPath) 119 + return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 + } 121 + 122 + v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 123 + return nil 124 + } 125 + 126 + func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 127 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 128 + 129 + // check if secret exists 130 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 131 + if err != nil || existing == nil { 132 + return ErrKeyNotFound 133 + } 134 + 135 + err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 136 + if err != nil { 137 + return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 + } 139 + 140 + v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 141 + return nil 142 + } 143 + 144 + func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 145 + repoPath := v.buildRepoPath(repo) 146 + 147 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 148 + if err != nil { 149 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 150 + return []LockedSecret{}, nil 151 + } 152 + return nil, fmt.Errorf("failed to list secrets: %w", err) 153 + } 154 + 155 + if secretsList == nil || secretsList.Data == nil { 156 + return []LockedSecret{}, nil 157 + } 158 + 159 + keys, ok := secretsList.Data["keys"].([]interface{}) 160 + if !ok { 161 + return []LockedSecret{}, nil 162 + } 163 + 164 + var secrets []LockedSecret 165 + 166 + for _, keyInterface := range keys { 167 + key, ok := keyInterface.(string) 168 + if !ok { 169 + continue 170 + } 171 + 172 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 173 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 174 + if err != nil { 175 + v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 + continue 177 + } 178 + 179 + if secretData == nil || secretData.Data == nil { 180 + continue 181 + } 182 + 183 + data := secretData.Data 184 + 185 + createdAtStr, ok := data["created_at"].(string) 186 + if !ok { 187 + createdAtStr = time.Now().Format(time.RFC3339) 188 + } 189 + 190 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 191 + if err != nil { 192 + createdAt = time.Now() 193 + } 194 + 195 + createdByStr, ok := data["created_by"].(string) 196 + if !ok { 197 + createdByStr = "" 198 + } 199 + 200 + keyStr, ok := data["key"].(string) 201 + if !ok { 202 + keyStr = key 203 + } 204 + 205 + secret := LockedSecret{ 206 + Key: keyStr, 207 + Repo: repo, 208 + CreatedAt: createdAt, 209 + CreatedBy: syntax.DID(createdByStr), 210 + } 211 + 212 + secrets = append(secrets, secret) 213 + } 214 + 215 + v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 216 + return secrets, nil 217 + } 218 + 219 + func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 220 + repoPath := v.buildRepoPath(repo) 221 + 222 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 223 + if err != nil { 224 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 225 + return []UnlockedSecret{}, nil 226 + } 227 + return nil, fmt.Errorf("failed to list secrets: %w", err) 228 + } 229 + 230 + if secretsList == nil || secretsList.Data == nil { 231 + return []UnlockedSecret{}, nil 232 + } 233 + 234 + keys, ok := secretsList.Data["keys"].([]interface{}) 235 + if !ok { 236 + return []UnlockedSecret{}, nil 237 + } 238 + 239 + var secrets []UnlockedSecret 240 + 241 + for _, keyInterface := range keys { 242 + key, ok := keyInterface.(string) 243 + if !ok { 244 + continue 245 + } 246 + 247 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 248 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 249 + if err != nil { 250 + v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 251 + continue 252 + } 253 + 254 + if secretData == nil || secretData.Data == nil { 255 + continue 256 + } 257 + 258 + data := secretData.Data 259 + 260 + valueStr, ok := data["value"].(string) 261 + if !ok { 262 + v.logger.Warn("secret missing value", "path", secretPath) 263 + continue 264 + } 265 + 266 + createdAtStr, ok := data["created_at"].(string) 267 + if !ok { 268 + createdAtStr = time.Now().Format(time.RFC3339) 269 + } 270 + 271 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 272 + if err != nil { 273 + createdAt = time.Now() 274 + } 275 + 276 + createdByStr, ok := data["created_by"].(string) 277 + if !ok { 278 + createdByStr = "" 279 + } 280 + 281 + keyStr, ok := data["key"].(string) 282 + if !ok { 283 + keyStr = key 284 + } 285 + 286 + secret := UnlockedSecret{ 287 + Key: keyStr, 288 + Value: valueStr, 289 + Repo: repo, 290 + CreatedAt: createdAt, 291 + CreatedBy: syntax.DID(createdByStr), 292 + } 293 + 294 + secrets = append(secrets, secret) 295 + } 296 + 297 + v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 298 + return secrets, nil 299 + } 300 + 301 + // buildRepoPath creates a safe path for a repository 302 + func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 303 + // convert DidSlashRepo to a safe path by replacing special characters 304 + repoPath := strings.ReplaceAll(string(repo), "/", "_") 305 + repoPath = strings.ReplaceAll(repoPath, ":", "_") 306 + repoPath = strings.ReplaceAll(repoPath, ".", "_") 307 + return fmt.Sprintf("repos/%s", repoPath) 308 + } 309 + 310 + // buildSecretPath creates a path for a specific secret 311 + func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 312 + return path.Join(v.buildRepoPath(repo), key) 313 + }
+605
spindle/secrets/openbao_test.go
···
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + "testing" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + // MockOpenBaoManager is a mock implementation of Manager interface for testing 15 + type MockOpenBaoManager struct { 16 + secrets map[string]UnlockedSecret // key: repo_key format 17 + shouldError bool 18 + errorToReturn error 19 + } 20 + 21 + func NewMockOpenBaoManager() *MockOpenBaoManager { 22 + return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)} 23 + } 24 + 25 + func (m *MockOpenBaoManager) SetError(err error) { 26 + m.shouldError = true 27 + m.errorToReturn = err 28 + } 29 + 30 + func (m *MockOpenBaoManager) ClearError() { 31 + m.shouldError = false 32 + m.errorToReturn = nil 33 + } 34 + 35 + func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { 36 + return string(repo) + "_" + key 37 + } 38 + 39 + func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 40 + if m.shouldError { 41 + return m.errorToReturn 42 + } 43 + 44 + key := m.buildKey(secret.Repo, secret.Key) 45 + if _, exists := m.secrets[key]; exists { 46 + return ErrKeyAlreadyPresent 47 + } 48 + 49 + m.secrets[key] = secret 50 + return nil 51 + } 52 + 53 + func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 54 + if m.shouldError { 55 + return m.errorToReturn 56 + } 57 + 58 + key := m.buildKey(secret.Repo, secret.Key) 59 + if _, exists := m.secrets[key]; !exists { 60 + return ErrKeyNotFound 61 + } 62 + 63 + delete(m.secrets, key) 64 + return nil 65 + } 66 + 67 + func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 68 + if m.shouldError { 69 + return nil, m.errorToReturn 70 + } 71 + 72 + var result []LockedSecret 73 + for _, secret := range m.secrets { 74 + if secret.Repo == repo { 75 + result = append(result, LockedSecret{ 76 + Key: secret.Key, 77 + Repo: secret.Repo, 78 + CreatedAt: secret.CreatedAt, 79 + CreatedBy: secret.CreatedBy, 80 + }) 81 + } 82 + } 83 + 84 + return result, nil 85 + } 86 + 87 + func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 88 + if m.shouldError { 89 + return nil, m.errorToReturn 90 + } 91 + 92 + var result []UnlockedSecret 93 + for _, secret := range m.secrets { 94 + if secret.Repo == repo { 95 + result = append(result, secret) 96 + } 97 + } 98 + 99 + return result, nil 100 + } 101 + 102 + func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret { 103 + return UnlockedSecret{ 104 + Key: key, 105 + Value: value, 106 + Repo: DidSlashRepo(repo), 107 + CreatedAt: time.Now(), 108 + CreatedBy: syntax.DID(createdBy), 109 + } 110 + } 111 + 112 + // Test MockOpenBaoManager interface compliance 113 + func TestMockOpenBaoManagerInterface(t *testing.T) { 114 + var _ Manager = (*MockOpenBaoManager)(nil) 115 + } 116 + 117 + func TestOpenBaoManagerInterface(t *testing.T) { 118 + var _ Manager = (*OpenBaoManager)(nil) 119 + } 120 + 121 + func TestNewOpenBaoManager(t *testing.T) { 122 + tests := []struct { 123 + name string 124 + proxyAddr string 125 + opts []OpenBaoManagerOpt 126 + expectError bool 127 + errorContains string 128 + }{ 129 + { 130 + name: "empty proxy address", 131 + proxyAddr: "", 132 + opts: nil, 133 + expectError: true, 134 + errorContains: "proxy address cannot be empty", 135 + }, 136 + { 137 + name: "valid proxy address", 138 + proxyAddr: "http://localhost:8200", 139 + opts: nil, 140 + expectError: true, // Will fail because no real proxy is running 141 + errorContains: "failed to connect to bao proxy", 142 + }, 143 + { 144 + name: "with mount path option", 145 + proxyAddr: "http://localhost:8200", 146 + opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")}, 147 + expectError: true, // Will fail because no real proxy is running 148 + errorContains: "failed to connect to bao proxy", 149 + }, 150 + } 151 + 152 + for _, tt := range tests { 153 + t.Run(tt.name, func(t *testing.T) { 154 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 156 + 157 + if tt.expectError { 158 + assert.Error(t, err) 159 + assert.Nil(t, manager) 160 + assert.Contains(t, err.Error(), tt.errorContains) 161 + } else { 162 + assert.NoError(t, err) 163 + assert.NotNil(t, manager) 164 + } 165 + }) 166 + } 167 + } 168 + 169 + func TestOpenBaoManager_PathBuilding(t *testing.T) { 170 + manager := &OpenBaoManager{mountPath: "secret"} 171 + 172 + tests := []struct { 173 + name string 174 + repo DidSlashRepo 175 + key string 176 + expected string 177 + }{ 178 + { 179 + name: "simple repo path", 180 + repo: DidSlashRepo("did:plc:foo/repo"), 181 + key: "api_key", 182 + expected: "repos/did_plc_foo_repo/api_key", 183 + }, 184 + { 185 + name: "complex repo path with dots", 186 + repo: DidSlashRepo("did:web:example.com/my-repo"), 187 + key: "secret_key", 188 + expected: "repos/did_web_example_com_my-repo/secret_key", 189 + }, 190 + } 191 + 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + result := manager.buildSecretPath(tt.repo, tt.key) 195 + assert.Equal(t, tt.expected, result) 196 + }) 197 + } 198 + } 199 + 200 + func TestOpenBaoManager_buildRepoPath(t *testing.T) { 201 + manager := &OpenBaoManager{mountPath: "test"} 202 + 203 + tests := []struct { 204 + name string 205 + repo DidSlashRepo 206 + expected string 207 + }{ 208 + { 209 + name: "simple repo", 210 + repo: "did:plc:test/myrepo", 211 + expected: "repos/did_plc_test_myrepo", 212 + }, 213 + { 214 + name: "repo with dots", 215 + repo: "did:plc:example.com/my.repo", 216 + expected: "repos/did_plc_example_com_my_repo", 217 + }, 218 + { 219 + name: "complex repo", 220 + repo: "did:web:example.com:8080/path/to/repo", 221 + expected: "repos/did_web_example_com_8080_path_to_repo", 222 + }, 223 + } 224 + 225 + for _, tt := range tests { 226 + t.Run(tt.name, func(t *testing.T) { 227 + result := manager.buildRepoPath(tt.repo) 228 + assert.Equal(t, tt.expected, result) 229 + }) 230 + } 231 + } 232 + 233 + func TestWithMountPath(t *testing.T) { 234 + manager := &OpenBaoManager{mountPath: "default"} 235 + 236 + opt := WithMountPath("custom-mount") 237 + opt(manager) 238 + 239 + assert.Equal(t, "custom-mount", manager.mountPath) 240 + } 241 + 242 + func TestMockOpenBaoManager_AddSecret(t *testing.T) { 243 + tests := []struct { 244 + name string 245 + secrets []UnlockedSecret 246 + expectError bool 247 + }{ 248 + { 249 + name: "add single secret", 250 + secrets: []UnlockedSecret{ 251 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 252 + }, 253 + expectError: false, 254 + }, 255 + { 256 + name: "add multiple secrets", 257 + secrets: []UnlockedSecret{ 258 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 259 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 260 + }, 261 + expectError: false, 262 + }, 263 + { 264 + name: "add duplicate secret", 265 + secrets: []UnlockedSecret{ 266 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 267 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"), 268 + }, 269 + expectError: true, 270 + }, 271 + } 272 + 273 + for _, tt := range tests { 274 + t.Run(tt.name, func(t *testing.T) { 275 + mock := NewMockOpenBaoManager() 276 + ctx := context.Background() 277 + var err error 278 + 279 + for i, secret := range tt.secrets { 280 + err = mock.AddSecret(ctx, secret) 281 + if tt.expectError && i == 1 { // Second secret should fail for duplicate test 282 + assert.Equal(t, ErrKeyAlreadyPresent, err) 283 + return 284 + } 285 + if !tt.expectError { 286 + assert.NoError(t, err) 287 + } 288 + } 289 + 290 + if !tt.expectError { 291 + assert.NoError(t, err) 292 + } 293 + }) 294 + } 295 + } 296 + 297 + func TestMockOpenBaoManager_RemoveSecret(t *testing.T) { 298 + tests := []struct { 299 + name string 300 + setupSecrets []UnlockedSecret 301 + removeSecret Secret[any] 302 + expectError bool 303 + }{ 304 + { 305 + name: "remove existing secret", 306 + setupSecrets: []UnlockedSecret{ 307 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 308 + }, 309 + removeSecret: Secret[any]{ 310 + Key: "API_KEY", 311 + Repo: DidSlashRepo("did:plc:test/repo1"), 312 + }, 313 + expectError: false, 314 + }, 315 + { 316 + name: "remove non-existent secret", 317 + setupSecrets: []UnlockedSecret{}, 318 + removeSecret: Secret[any]{ 319 + Key: "API_KEY", 320 + Repo: DidSlashRepo("did:plc:test/repo1"), 321 + }, 322 + expectError: true, 323 + }, 324 + } 325 + 326 + for _, tt := range tests { 327 + t.Run(tt.name, func(t *testing.T) { 328 + mock := NewMockOpenBaoManager() 329 + ctx := context.Background() 330 + 331 + // Setup secrets 332 + for _, secret := range tt.setupSecrets { 333 + err := mock.AddSecret(ctx, secret) 334 + assert.NoError(t, err) 335 + } 336 + 337 + // Remove secret 338 + err := mock.RemoveSecret(ctx, tt.removeSecret) 339 + 340 + if tt.expectError { 341 + assert.Equal(t, ErrKeyNotFound, err) 342 + } else { 343 + assert.NoError(t, err) 344 + } 345 + }) 346 + } 347 + } 348 + 349 + func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) { 350 + tests := []struct { 351 + name string 352 + setupSecrets []UnlockedSecret 353 + queryRepo DidSlashRepo 354 + expectedCount int 355 + expectedKeys []string 356 + expectError bool 357 + }{ 358 + { 359 + name: "get secrets from repo with secrets", 360 + setupSecrets: []UnlockedSecret{ 361 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 362 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 363 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 364 + }, 365 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 366 + expectedCount: 2, 367 + expectedKeys: []string{"API_KEY", "DB_PASSWORD"}, 368 + expectError: false, 369 + }, 370 + { 371 + name: "get secrets from empty repo", 372 + setupSecrets: []UnlockedSecret{}, 373 + queryRepo: DidSlashRepo("did:plc:test/empty"), 374 + expectedCount: 0, 375 + expectedKeys: []string{}, 376 + expectError: false, 377 + }, 378 + } 379 + 380 + for _, tt := range tests { 381 + t.Run(tt.name, func(t *testing.T) { 382 + mock := NewMockOpenBaoManager() 383 + ctx := context.Background() 384 + 385 + // Setup 386 + for _, secret := range tt.setupSecrets { 387 + err := mock.AddSecret(ctx, secret) 388 + assert.NoError(t, err) 389 + } 390 + 391 + // Test 392 + secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo) 393 + 394 + if tt.expectError { 395 + assert.Error(t, err) 396 + } else { 397 + assert.NoError(t, err) 398 + assert.Len(t, secrets, tt.expectedCount) 399 + 400 + // Check keys 401 + actualKeys := make([]string, len(secrets)) 402 + for i, secret := range secrets { 403 + actualKeys[i] = secret.Key 404 + } 405 + 406 + for _, expectedKey := range tt.expectedKeys { 407 + assert.Contains(t, actualKeys, expectedKey) 408 + } 409 + } 410 + }) 411 + } 412 + } 413 + 414 + func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) { 415 + tests := []struct { 416 + name string 417 + setupSecrets []UnlockedSecret 418 + queryRepo DidSlashRepo 419 + expectedCount int 420 + expectedSecrets map[string]string // key -> value 421 + expectError bool 422 + }{ 423 + { 424 + name: "get unlocked secrets from repo", 425 + setupSecrets: []UnlockedSecret{ 426 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 427 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 428 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 429 + }, 430 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 431 + expectedCount: 2, 432 + expectedSecrets: map[string]string{ 433 + "API_KEY": "secret123", 434 + "DB_PASSWORD": "dbpass456", 435 + }, 436 + expectError: false, 437 + }, 438 + { 439 + name: "get secrets from empty repo", 440 + setupSecrets: []UnlockedSecret{}, 441 + queryRepo: DidSlashRepo("did:plc:test/empty"), 442 + expectedCount: 0, 443 + expectedSecrets: map[string]string{}, 444 + expectError: false, 445 + }, 446 + } 447 + 448 + for _, tt := range tests { 449 + t.Run(tt.name, func(t *testing.T) { 450 + mock := NewMockOpenBaoManager() 451 + ctx := context.Background() 452 + 453 + // Setup 454 + for _, secret := range tt.setupSecrets { 455 + err := mock.AddSecret(ctx, secret) 456 + assert.NoError(t, err) 457 + } 458 + 459 + // Test 460 + secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo) 461 + 462 + if tt.expectError { 463 + assert.Error(t, err) 464 + } else { 465 + assert.NoError(t, err) 466 + assert.Len(t, secrets, tt.expectedCount) 467 + 468 + // Check key-value pairs 469 + actualSecrets := make(map[string]string) 470 + for _, secret := range secrets { 471 + actualSecrets[secret.Key] = secret.Value 472 + } 473 + 474 + for expectedKey, expectedValue := range tt.expectedSecrets { 475 + actualValue, exists := actualSecrets[expectedKey] 476 + assert.True(t, exists, "Expected key %s not found", expectedKey) 477 + assert.Equal(t, expectedValue, actualValue) 478 + } 479 + } 480 + }) 481 + } 482 + } 483 + 484 + func TestMockOpenBaoManager_ErrorHandling(t *testing.T) { 485 + mock := NewMockOpenBaoManager() 486 + ctx := context.Background() 487 + testError := assert.AnError 488 + 489 + // Test error injection 490 + mock.SetError(testError) 491 + 492 + secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator") 493 + 494 + // All operations should return the injected error 495 + err := mock.AddSecret(ctx, secret) 496 + assert.Equal(t, testError, err) 497 + 498 + _, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1") 499 + assert.Equal(t, testError, err) 500 + 501 + _, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1") 502 + assert.Equal(t, testError, err) 503 + 504 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"}) 505 + assert.Equal(t, testError, err) 506 + 507 + // Clear error and test normal operation 508 + mock.ClearError() 509 + err = mock.AddSecret(ctx, secret) 510 + assert.NoError(t, err) 511 + } 512 + 513 + func TestMockOpenBaoManager_Integration(t *testing.T) { 514 + tests := []struct { 515 + name string 516 + scenario func(t *testing.T, mock *MockOpenBaoManager) 517 + }{ 518 + { 519 + name: "complete workflow", 520 + scenario: func(t *testing.T, mock *MockOpenBaoManager) { 521 + ctx := context.Background() 522 + repo := DidSlashRepo("did:plc:test/integration") 523 + 524 + // Start with empty repo 525 + secrets, err := mock.GetSecretsLocked(ctx, repo) 526 + assert.NoError(t, err) 527 + assert.Empty(t, secrets) 528 + 529 + // Add some secrets 530 + secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator") 531 + secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator") 532 + 533 + err = mock.AddSecret(ctx, secret1) 534 + assert.NoError(t, err) 535 + 536 + err = mock.AddSecret(ctx, secret2) 537 + assert.NoError(t, err) 538 + 539 + // Verify secrets exist 540 + secrets, err = mock.GetSecretsLocked(ctx, repo) 541 + assert.NoError(t, err) 542 + assert.Len(t, secrets, 2) 543 + 544 + unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo) 545 + assert.NoError(t, err) 546 + assert.Len(t, unlockedSecrets, 2) 547 + 548 + // Remove one secret 549 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo}) 550 + assert.NoError(t, err) 551 + 552 + // Verify only one secret remains 553 + secrets, err = mock.GetSecretsLocked(ctx, repo) 554 + assert.NoError(t, err) 555 + assert.Len(t, secrets, 1) 556 + assert.Equal(t, "DB_PASSWORD", secrets[0].Key) 557 + }, 558 + }, 559 + } 560 + 561 + for _, tt := range tests { 562 + t.Run(tt.name, func(t *testing.T) { 563 + mock := NewMockOpenBaoManager() 564 + tt.scenario(t, mock) 565 + }) 566 + } 567 + } 568 + 569 + func TestOpenBaoManager_ProxyConfiguration(t *testing.T) { 570 + tests := []struct { 571 + name string 572 + proxyAddr string 573 + description string 574 + }{ 575 + { 576 + name: "default_localhost", 577 + proxyAddr: "http://127.0.0.1:8200", 578 + description: "Should connect to default localhost proxy", 579 + }, 580 + { 581 + name: "custom_host", 582 + proxyAddr: "http://bao-proxy:8200", 583 + description: "Should connect to custom proxy host", 584 + }, 585 + { 586 + name: "https_proxy", 587 + proxyAddr: "https://127.0.0.1:8200", 588 + description: "Should connect to HTTPS proxy", 589 + }, 590 + } 591 + 592 + for _, tt := range tests { 593 + t.Run(tt.name, func(t *testing.T) { 594 + t.Log("Testing scenario:", tt.description) 595 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 596 + 597 + // All these will fail because no real proxy is running 598 + // but we can test that the configuration is properly accepted 599 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 600 + assert.Error(t, err) // Expected because no real proxy 601 + assert.Nil(t, manager) 602 + assert.Contains(t, err.Error(), "failed to connect to bao proxy") 603 + }) 604 + } 605 + }
+22
spindle/secrets/policy.hcl
···
··· 1 + # Allow full access to the spindle KV mount 2 + path "spindle/*" { 3 + capabilities = ["create", "read", "update", "delete", "list"] 4 + } 5 + 6 + path "spindle/data/*" { 7 + capabilities = ["create", "read", "update", "delete"] 8 + } 9 + 10 + path "spindle/metadata/*" { 11 + capabilities = ["list", "read", "delete"] 12 + } 13 + 14 + # Allow listing mounts (for connection testing) 15 + path "sys/mounts" { 16 + capabilities = ["read"] 17 + } 18 + 19 + # Allow token self-lookup (for health checks) 20 + path "auth/token/lookup-self" { 21 + capabilities = ["read"] 22 + }
+172
spindle/secrets/sqlite.go
···
··· 1 + // an sqlite3 backed secret manager 2 + package secrets 3 + 4 + import ( 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "time" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 12 + 13 + type SqliteManager struct { 14 + db *sql.DB 15 + tableName string 16 + } 17 + 18 + type SqliteManagerOpt func(*SqliteManager) 19 + 20 + func WithTableName(name string) SqliteManagerOpt { 21 + return func(s *SqliteManager) { 22 + s.tableName = name 23 + } 24 + } 25 + 26 + func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 + db, err := sql.Open("sqlite3", dbPath) 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 + } 31 + 32 + manager := &SqliteManager{ 33 + db: db, 34 + tableName: "secrets", 35 + } 36 + 37 + for _, o := range opts { 38 + o(manager) 39 + } 40 + 41 + if err := manager.init(); err != nil { 42 + return nil, err 43 + } 44 + 45 + return manager, nil 46 + } 47 + 48 + // creates a table and sets up the schema, migrations if any can go here 49 + func (s *SqliteManager) init() error { 50 + createTable := 51 + `create table if not exists ` + s.tableName + `( 52 + id integer primary key autoincrement, 53 + repo text not null, 54 + key text not null, 55 + value text not null, 56 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 57 + created_by text not null, 58 + 59 + unique(repo, key) 60 + );` 61 + _, err := s.db.Exec(createTable) 62 + return err 63 + } 64 + 65 + func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 66 + query := fmt.Sprintf(` 67 + insert or ignore into %s (repo, key, value, created_by) 68 + values (?, ?, ?, ?); 69 + `, s.tableName) 70 + 71 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy) 72 + if err != nil { 73 + return err 74 + } 75 + 76 + num, err := res.RowsAffected() 77 + if err != nil { 78 + return err 79 + } 80 + 81 + if num == 0 { 82 + return ErrKeyAlreadyPresent 83 + } 84 + 85 + return nil 86 + } 87 + 88 + func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 89 + query := fmt.Sprintf(` 90 + delete from %s where repo = ? and key = ?; 91 + `, s.tableName) 92 + 93 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key) 94 + if err != nil { 95 + return err 96 + } 97 + 98 + num, err := res.RowsAffected() 99 + if err != nil { 100 + return err 101 + } 102 + 103 + if num == 0 { 104 + return ErrKeyNotFound 105 + } 106 + 107 + return nil 108 + } 109 + 110 + func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) { 111 + query := fmt.Sprintf(` 112 + select repo, key, created_at, created_by from %s where repo = ?; 113 + `, s.tableName) 114 + 115 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 116 + if err != nil { 117 + return nil, err 118 + } 119 + 120 + var ls []LockedSecret 121 + for rows.Next() { 122 + var l LockedSecret 123 + var createdAt string 124 + if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil { 125 + return nil, err 126 + } 127 + 128 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 129 + l.CreatedAt = t 130 + } 131 + 132 + ls = append(ls, l) 133 + } 134 + 135 + if err = rows.Err(); err != nil { 136 + return nil, err 137 + } 138 + 139 + return ls, nil 140 + } 141 + 142 + func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) { 143 + query := fmt.Sprintf(` 144 + select repo, key, value, created_at, created_by from %s where repo = ?; 145 + `, s.tableName) 146 + 147 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 148 + if err != nil { 149 + return nil, err 150 + } 151 + 152 + var ls []UnlockedSecret 153 + for rows.Next() { 154 + var l UnlockedSecret 155 + var createdAt string 156 + if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil { 157 + return nil, err 158 + } 159 + 160 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 161 + l.CreatedAt = t 162 + } 163 + 164 + ls = append(ls, l) 165 + } 166 + 167 + if err = rows.Err(); err != nil { 168 + return nil, err 169 + } 170 + 171 + return ls, nil 172 + }
+590
spindle/secrets/sqlite_test.go
···
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "github.com/alecthomas/assert/v2" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + func createInMemoryDB(t *testing.T) *SqliteManager { 13 + t.Helper() 14 + manager, err := NewSQLiteManager(":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory manager: %v", err) 17 + } 18 + return manager 19 + } 20 + 21 + func createTestSecret(repo, key, value, createdBy string) UnlockedSecret { 22 + return UnlockedSecret{ 23 + Key: key, 24 + Value: value, 25 + Repo: DidSlashRepo(repo), 26 + CreatedAt: time.Now(), 27 + CreatedBy: syntax.DID(createdBy), 28 + } 29 + } 30 + 31 + // ensure that interface is satisfied 32 + func TestManagerInterface(t *testing.T) { 33 + var _ Manager = (*SqliteManager)(nil) 34 + } 35 + 36 + func TestNewSQLiteManager(t *testing.T) { 37 + tests := []struct { 38 + name string 39 + dbPath string 40 + opts []SqliteManagerOpt 41 + expectError bool 42 + expectTable string 43 + }{ 44 + { 45 + name: "default table name", 46 + dbPath: ":memory:", 47 + opts: nil, 48 + expectError: false, 49 + expectTable: "secrets", 50 + }, 51 + { 52 + name: "custom table name", 53 + dbPath: ":memory:", 54 + opts: []SqliteManagerOpt{WithTableName("custom_secrets")}, 55 + expectError: false, 56 + expectTable: "custom_secrets", 57 + }, 58 + { 59 + name: "invalid database path", 60 + dbPath: "/invalid/path/to/database.db", 61 + opts: nil, 62 + expectError: true, 63 + expectTable: "", 64 + }, 65 + } 66 + 67 + for _, tt := range tests { 68 + t.Run(tt.name, func(t *testing.T) { 69 + manager, err := NewSQLiteManager(tt.dbPath, tt.opts...) 70 + if tt.expectError { 71 + if err == nil { 72 + t.Error("Expected error but got none") 73 + } 74 + return 75 + } 76 + 77 + if err != nil { 78 + t.Fatalf("Unexpected error: %v", err) 79 + } 80 + defer manager.db.Close() 81 + 82 + if manager.tableName != tt.expectTable { 83 + t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName) 84 + } 85 + }) 86 + } 87 + } 88 + 89 + func TestSqliteManager_AddSecret(t *testing.T) { 90 + tests := []struct { 91 + name string 92 + secrets []UnlockedSecret 93 + expectError []error 94 + }{ 95 + { 96 + name: "add single secret", 97 + secrets: []UnlockedSecret{ 98 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 99 + }, 100 + expectError: []error{nil}, 101 + }, 102 + { 103 + name: "add multiple unique secrets", 104 + secrets: []UnlockedSecret{ 105 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 106 + createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"), 107 + createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"), 108 + }, 109 + expectError: []error{nil, nil, nil}, 110 + }, 111 + { 112 + name: "add duplicate secret", 113 + secrets: []UnlockedSecret{ 114 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 115 + createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"), 116 + }, 117 + expectError: []error{nil, ErrKeyAlreadyPresent}, 118 + }, 119 + } 120 + 121 + for _, tt := range tests { 122 + t.Run(tt.name, func(t *testing.T) { 123 + manager := createInMemoryDB(t) 124 + defer manager.db.Close() 125 + 126 + for i, secret := range tt.secrets { 127 + err := manager.AddSecret(context.Background(), secret) 128 + if err != tt.expectError[i] { 129 + t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err) 130 + } 131 + } 132 + }) 133 + } 134 + } 135 + 136 + func TestSqliteManager_RemoveSecret(t *testing.T) { 137 + tests := []struct { 138 + name string 139 + setupSecrets []UnlockedSecret 140 + removeSecret Secret[any] 141 + expectError error 142 + }{ 143 + { 144 + name: "remove existing secret", 145 + setupSecrets: []UnlockedSecret{ 146 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 147 + }, 148 + removeSecret: Secret[any]{ 149 + Key: "api_key", 150 + Repo: DidSlashRepo("did:plc:foo/repo"), 151 + }, 152 + expectError: nil, 153 + }, 154 + { 155 + name: "remove non-existent secret", 156 + setupSecrets: []UnlockedSecret{ 157 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 158 + }, 159 + removeSecret: Secret[any]{ 160 + Key: "non_existent_key", 161 + Repo: DidSlashRepo("did:plc:foo/repo"), 162 + }, 163 + expectError: ErrKeyNotFound, 164 + }, 165 + { 166 + name: "remove from empty database", 167 + setupSecrets: []UnlockedSecret{}, 168 + removeSecret: Secret[any]{ 169 + Key: "any_key", 170 + Repo: DidSlashRepo("did:plc:foo/repo"), 171 + }, 172 + expectError: ErrKeyNotFound, 173 + }, 174 + { 175 + name: "remove secret from wrong repo", 176 + setupSecrets: []UnlockedSecret{ 177 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 178 + }, 179 + removeSecret: Secret[any]{ 180 + Key: "api_key", 181 + Repo: DidSlashRepo("other.com/repo"), 182 + }, 183 + expectError: ErrKeyNotFound, 184 + }, 185 + } 186 + 187 + for _, tt := range tests { 188 + t.Run(tt.name, func(t *testing.T) { 189 + manager := createInMemoryDB(t) 190 + defer manager.db.Close() 191 + 192 + // Setup secrets 193 + for _, secret := range tt.setupSecrets { 194 + if err := manager.AddSecret(context.Background(), secret); err != nil { 195 + t.Fatalf("Failed to setup secret: %v", err) 196 + } 197 + } 198 + 199 + // Test removal 200 + err := manager.RemoveSecret(context.Background(), tt.removeSecret) 201 + if err != tt.expectError { 202 + t.Errorf("Expected error %v, got %v", tt.expectError, err) 203 + } 204 + }) 205 + } 206 + } 207 + 208 + func TestSqliteManager_GetSecretsLocked(t *testing.T) { 209 + tests := []struct { 210 + name string 211 + setupSecrets []UnlockedSecret 212 + queryRepo DidSlashRepo 213 + expectedCount int 214 + expectedKeys []string 215 + expectError bool 216 + }{ 217 + { 218 + name: "get secrets for repo with multiple secrets", 219 + setupSecrets: []UnlockedSecret{ 220 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 221 + createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"), 222 + createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"), 223 + }, 224 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 225 + expectedCount: 2, 226 + expectedKeys: []string{"key1", "key2"}, 227 + expectError: false, 228 + }, 229 + { 230 + name: "get secrets for repo with single secret", 231 + setupSecrets: []UnlockedSecret{ 232 + createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"), 233 + createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"), 234 + }, 235 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 236 + expectedCount: 1, 237 + expectedKeys: []string{"single_key"}, 238 + expectError: false, 239 + }, 240 + { 241 + name: "get secrets for non-existent repo", 242 + setupSecrets: []UnlockedSecret{ 243 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 244 + }, 245 + queryRepo: DidSlashRepo("nonexistent.com/repo"), 246 + expectedCount: 0, 247 + expectedKeys: []string{}, 248 + expectError: false, 249 + }, 250 + { 251 + name: "get secrets from empty database", 252 + setupSecrets: []UnlockedSecret{}, 253 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 254 + expectedCount: 0, 255 + expectedKeys: []string{}, 256 + expectError: false, 257 + }, 258 + } 259 + 260 + for _, tt := range tests { 261 + t.Run(tt.name, func(t *testing.T) { 262 + manager := createInMemoryDB(t) 263 + defer manager.db.Close() 264 + 265 + // Setup secrets 266 + for _, secret := range tt.setupSecrets { 267 + if err := manager.AddSecret(context.Background(), secret); err != nil { 268 + t.Fatalf("Failed to setup secret: %v", err) 269 + } 270 + } 271 + 272 + // Test getting locked secrets 273 + lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo) 274 + if tt.expectError && err == nil { 275 + t.Error("Expected error but got none") 276 + return 277 + } 278 + if !tt.expectError && err != nil { 279 + t.Fatalf("Unexpected error: %v", err) 280 + } 281 + 282 + if len(lockedSecrets) != tt.expectedCount { 283 + t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets)) 284 + } 285 + 286 + // Verify keys and that values are not present (locked) 287 + foundKeys := make(map[string]bool) 288 + for _, ls := range lockedSecrets { 289 + foundKeys[ls.Key] = true 290 + if ls.Repo != tt.queryRepo { 291 + t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo) 292 + } 293 + if ls.CreatedBy == "" { 294 + t.Error("Expected CreatedBy to be present") 295 + } 296 + if ls.CreatedAt.IsZero() { 297 + t.Error("Expected CreatedAt to be set") 298 + } 299 + } 300 + 301 + for _, expectedKey := range tt.expectedKeys { 302 + if !foundKeys[expectedKey] { 303 + t.Errorf("Expected key %s not found", expectedKey) 304 + } 305 + } 306 + }) 307 + } 308 + } 309 + 310 + func TestSqliteManager_GetSecretsUnlocked(t *testing.T) { 311 + tests := []struct { 312 + name string 313 + setupSecrets []UnlockedSecret 314 + queryRepo DidSlashRepo 315 + expectedCount int 316 + expectedSecrets map[string]string // key -> value 317 + expectError bool 318 + }{ 319 + { 320 + name: "get unlocked secrets for repo with multiple secrets", 321 + setupSecrets: []UnlockedSecret{ 322 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 323 + createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"), 324 + createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"), 325 + }, 326 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 327 + expectedCount: 2, 328 + expectedSecrets: map[string]string{ 329 + "key1": "value1", 330 + "key2": "value2", 331 + }, 332 + expectError: false, 333 + }, 334 + { 335 + name: "get unlocked secrets for repo with single secret", 336 + setupSecrets: []UnlockedSecret{ 337 + createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"), 338 + createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"), 339 + }, 340 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 341 + expectedCount: 1, 342 + expectedSecrets: map[string]string{ 343 + "single_key": "single_value", 344 + }, 345 + expectError: false, 346 + }, 347 + { 348 + name: "get unlocked secrets for non-existent repo", 349 + setupSecrets: []UnlockedSecret{ 350 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 351 + }, 352 + queryRepo: DidSlashRepo("nonexistent.com/repo"), 353 + expectedCount: 0, 354 + expectedSecrets: map[string]string{}, 355 + expectError: false, 356 + }, 357 + { 358 + name: "get unlocked secrets from empty database", 359 + setupSecrets: []UnlockedSecret{}, 360 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 361 + expectedCount: 0, 362 + expectedSecrets: map[string]string{}, 363 + expectError: false, 364 + }, 365 + } 366 + 367 + for _, tt := range tests { 368 + t.Run(tt.name, func(t *testing.T) { 369 + manager := createInMemoryDB(t) 370 + defer manager.db.Close() 371 + 372 + // Setup secrets 373 + for _, secret := range tt.setupSecrets { 374 + if err := manager.AddSecret(context.Background(), secret); err != nil { 375 + t.Fatalf("Failed to setup secret: %v", err) 376 + } 377 + } 378 + 379 + // Test getting unlocked secrets 380 + unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo) 381 + if tt.expectError && err == nil { 382 + t.Error("Expected error but got none") 383 + return 384 + } 385 + if !tt.expectError && err != nil { 386 + t.Fatalf("Unexpected error: %v", err) 387 + } 388 + 389 + if len(unlockedSecrets) != tt.expectedCount { 390 + t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets)) 391 + } 392 + 393 + // Verify keys, values, and metadata 394 + for _, us := range unlockedSecrets { 395 + expectedValue, exists := tt.expectedSecrets[us.Key] 396 + if !exists { 397 + t.Errorf("Unexpected key: %s", us.Key) 398 + continue 399 + } 400 + if us.Value != expectedValue { 401 + t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value) 402 + } 403 + if us.Repo != tt.queryRepo { 404 + t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo) 405 + } 406 + if us.CreatedBy == "" { 407 + t.Error("Expected CreatedBy to be present") 408 + } 409 + if us.CreatedAt.IsZero() { 410 + t.Error("Expected CreatedAt to be set") 411 + } 412 + } 413 + }) 414 + } 415 + } 416 + 417 + // Test that demonstrates interface usage with table-driven tests 418 + func TestManagerInterface_Usage(t *testing.T) { 419 + tests := []struct { 420 + name string 421 + operations []func(Manager) error 422 + expectError bool 423 + }{ 424 + { 425 + name: "successful workflow", 426 + operations: []func(Manager) error{ 427 + func(m Manager) error { 428 + secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user") 429 + return m.AddSecret(context.Background(), secret) 430 + }, 431 + func(m Manager) error { 432 + _, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo")) 433 + return err 434 + }, 435 + func(m Manager) error { 436 + _, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo")) 437 + return err 438 + }, 439 + func(m Manager) error { 440 + secret := Secret[any]{ 441 + Key: "test_key", 442 + Repo: DidSlashRepo("interface.test/repo"), 443 + } 444 + return m.RemoveSecret(context.Background(), secret) 445 + }, 446 + }, 447 + expectError: false, 448 + }, 449 + { 450 + name: "error on duplicate key", 451 + operations: []func(Manager) error{ 452 + func(m Manager) error { 453 + secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user") 454 + return m.AddSecret(context.Background(), secret) 455 + }, 456 + func(m Manager) error { 457 + secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user") 458 + return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent 459 + }, 460 + }, 461 + expectError: true, 462 + }, 463 + } 464 + 465 + for _, tt := range tests { 466 + t.Run(tt.name, func(t *testing.T) { 467 + var manager Manager = createInMemoryDB(t) 468 + defer func() { 469 + if sqliteManager, ok := manager.(*SqliteManager); ok { 470 + sqliteManager.db.Close() 471 + } 472 + }() 473 + 474 + var finalErr error 475 + for i, operation := range tt.operations { 476 + if err := operation(manager); err != nil { 477 + finalErr = err 478 + t.Logf("Operation %d returned error: %v", i, err) 479 + } 480 + } 481 + 482 + if tt.expectError && finalErr == nil { 483 + t.Error("Expected error but got none") 484 + } 485 + if !tt.expectError && finalErr != nil { 486 + t.Errorf("Unexpected error: %v", finalErr) 487 + } 488 + }) 489 + } 490 + } 491 + 492 + // Integration test with table-driven scenarios 493 + func TestSqliteManager_Integration(t *testing.T) { 494 + tests := []struct { 495 + name string 496 + scenario func(*testing.T, *SqliteManager) 497 + }{ 498 + { 499 + name: "multi-repo secret management", 500 + scenario: func(t *testing.T, manager *SqliteManager) { 501 + repo1 := DidSlashRepo("example1.com/repo") 502 + repo2 := DidSlashRepo("example2.com/repo") 503 + 504 + secrets := []UnlockedSecret{ 505 + createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"), 506 + createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"), 507 + createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"), 508 + } 509 + 510 + // Add all secrets 511 + for _, secret := range secrets { 512 + if err := manager.AddSecret(context.Background(), secret); err != nil { 513 + t.Fatalf("Failed to add secret %s: %v", secret.Key, err) 514 + } 515 + } 516 + 517 + // Verify counts 518 + locked1, _ := manager.GetSecretsLocked(context.Background(), repo1) 519 + locked2, _ := manager.GetSecretsLocked(context.Background(), repo2) 520 + 521 + if len(locked1) != 2 { 522 + t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1)) 523 + } 524 + if len(locked2) != 1 { 525 + t.Errorf("Expected 1 secret for repo2, got %d", len(locked2)) 526 + } 527 + 528 + // Remove and verify 529 + secretToRemove := Secret[any]{Key: "db_password", Repo: repo1} 530 + if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil { 531 + t.Fatalf("Failed to remove secret: %v", err) 532 + } 533 + 534 + locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1) 535 + if len(locked1After) != 1 { 536 + t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After)) 537 + } 538 + if locked1After[0].Key != "api_key" { 539 + t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key) 540 + } 541 + }, 542 + }, 543 + { 544 + name: "empty database operations", 545 + scenario: func(t *testing.T, manager *SqliteManager) { 546 + repo := DidSlashRepo("empty.test/repo") 547 + 548 + // Operations on empty database should not error 549 + locked, err := manager.GetSecretsLocked(context.Background(), repo) 550 + if err != nil { 551 + t.Errorf("GetSecretsLocked on empty DB failed: %v", err) 552 + } 553 + if len(locked) != 0 { 554 + t.Errorf("Expected 0 secrets, got %d", len(locked)) 555 + } 556 + 557 + unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo) 558 + if err != nil { 559 + t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err) 560 + } 561 + if len(unlocked) != 0 { 562 + t.Errorf("Expected 0 secrets, got %d", len(unlocked)) 563 + } 564 + 565 + // Remove from empty should return ErrKeyNotFound 566 + nonExistent := Secret[any]{Key: "none", Repo: repo} 567 + err = manager.RemoveSecret(context.Background(), nonExistent) 568 + if err != ErrKeyNotFound { 569 + t.Errorf("Expected ErrKeyNotFound, got %v", err) 570 + } 571 + }, 572 + }, 573 + } 574 + 575 + for _, tt := range tests { 576 + t.Run(tt.name, func(t *testing.T) { 577 + manager := createInMemoryDB(t) 578 + defer manager.db.Close() 579 + tt.scenario(t, manager) 580 + }) 581 + } 582 + } 583 + 584 + func TestSqliteManager_StopperInterface(t *testing.T) { 585 + manager := &SqliteManager{} 586 + 587 + // Verify that SqliteManager does NOT implement the Stopper interface 588 + _, ok := interface{}(manager).(Stopper) 589 + assert.False(t, ok, "SqliteManager should NOT implement Stopper interface") 590 + }
+81 -42
spindle/server.go
··· 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" ··· 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/eventconsumer" 13 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 14 "tangled.sh/tangled.sh/core/jetstream" 15 "tangled.sh/tangled.sh/core/log" 16 "tangled.sh/tangled.sh/core/notifier" ··· 20 "tangled.sh/tangled.sh/core/spindle/engine" 21 "tangled.sh/tangled.sh/core/spindle/models" 22 "tangled.sh/tangled.sh/core/spindle/queue" 23 ) 24 25 const ( 26 rbacDomain = "thisserver" 27 ) 28 29 type Spindle struct { 30 - jc *jetstream.JetstreamClient 31 - db *db.DB 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 35 - eng *engine.Engine 36 - jq *queue.Queue 37 - cfg *config.Config 38 - ks *eventconsumer.Consumer 39 } 40 41 func Run(ctx context.Context) error { ··· 59 60 n := notifier.New() 61 62 - eng, err := engine.New(ctx, cfg, d, &n) 63 if err != nil { 64 return err 65 } ··· 69 collections := []string{ 70 tangled.SpindleMemberNSID, 71 tangled.RepoNSID, 72 } 73 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 if err != nil { ··· 76 } 77 jc.AddDid(cfg.Server.Owner) 78 79 spindle := Spindle{ 80 - jc: jc, 81 - e: e, 82 - db: d, 83 - l: logger, 84 - n: &n, 85 - eng: eng, 86 - jq: jq, 87 - cfg: cfg, 88 } 89 90 err = e.AddSpindle(rbacDomain) ··· 100 // starts a job queue runner in the background 101 jq.Start() 102 defer jq.Stop() 103 104 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 if err != nil { ··· 144 mux := chi.NewRouter() 145 146 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 147 - w.Write([]byte( 148 - ` **** 149 - *** *** 150 - *** ** ****** ** 151 - ** * ***** 152 - * ** ** 153 - * * * *************** 154 - ** ** *# ** 155 - * ** ** *** ** 156 - * * ** ** * ****** 157 - * ** ** * ** * * 158 - ** ** *** ** ** * 159 - ** ** * ** * * 160 - ** **** ** * * 161 - ** *** ** ** ** 162 - *** ** ***** 163 - ******************** 164 - ** 165 - * 166 - #************** 167 - ** 168 - ******** 169 - 170 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`)) 171 }) 172 mux.HandleFunc("/events", s.Events) 173 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 174 w.Write([]byte(s.cfg.Server.Owner)) 175 }) 176 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 177 return mux 178 } 179 180 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
··· 2 3 import ( 4 "context" 5 + _ "embed" 6 "encoding/json" 7 "fmt" 8 "log/slog" ··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/eventconsumer" 14 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 + "tangled.sh/tangled.sh/core/idresolver" 16 "tangled.sh/tangled.sh/core/jetstream" 17 "tangled.sh/tangled.sh/core/log" 18 "tangled.sh/tangled.sh/core/notifier" ··· 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 "tangled.sh/tangled.sh/core/spindle/models" 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 + "tangled.sh/tangled.sh/core/spindle/xrpc" 27 ) 28 29 + //go:embed motd 30 + var motd []byte 31 + 32 const ( 33 rbacDomain = "thisserver" 34 ) 35 36 type Spindle struct { 37 + jc *jetstream.JetstreamClient 38 + db *db.DB 39 + e *rbac.Enforcer 40 + l *slog.Logger 41 + n *notifier.Notifier 42 + eng *engine.Engine 43 + jq *queue.Queue 44 + cfg *config.Config 45 + ks *eventconsumer.Consumer 46 + res *idresolver.Resolver 47 + vault secrets.Manager 48 } 49 50 func Run(ctx context.Context) error { ··· 68 69 n := notifier.New() 70 71 + var vault secrets.Manager 72 + switch cfg.Server.Secrets.Provider { 73 + case "openbao": 74 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 75 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 76 + } 77 + vault, err = secrets.NewOpenBaoManager( 78 + cfg.Server.Secrets.OpenBao.ProxyAddr, 79 + logger, 80 + secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 81 + ) 82 + if err != nil { 83 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 84 + } 85 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 86 + case "sqlite", "": 87 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 88 + if err != nil { 89 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 90 + } 91 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 92 + default: 93 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 + } 95 + 96 + eng, err := engine.New(ctx, cfg, d, &n, vault) 97 if err != nil { 98 return err 99 } ··· 103 collections := []string{ 104 tangled.SpindleMemberNSID, 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 107 } 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 109 if err != nil { ··· 111 } 112 jc.AddDid(cfg.Server.Owner) 113 114 + resolver := idresolver.DefaultResolver() 115 + 116 spindle := Spindle{ 117 + jc: jc, 118 + e: e, 119 + db: d, 120 + l: logger, 121 + n: &n, 122 + eng: eng, 123 + jq: jq, 124 + cfg: cfg, 125 + res: resolver, 126 + vault: vault, 127 } 128 129 err = e.AddSpindle(rbacDomain) ··· 139 // starts a job queue runner in the background 140 jq.Start() 141 defer jq.Stop() 142 + 143 + // Stop vault token renewal if it implements Stopper 144 + if stopper, ok := vault.(secrets.Stopper); ok { 145 + defer stopper.Stop() 146 + } 147 148 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 149 if err != nil { ··· 188 mux := chi.NewRouter() 189 190 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 191 + w.Write(motd) 192 }) 193 mux.HandleFunc("/events", s.Events) 194 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 195 w.Write([]byte(s.cfg.Server.Owner)) 196 }) 197 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 198 + 199 + mux.Mount("/xrpc", s.XrpcRouter()) 200 return mux 201 + } 202 + 203 + func (s *Spindle) XrpcRouter() http.Handler { 204 + logger := s.l.With("route", "xrpc") 205 + 206 + x := xrpc.Xrpc{ 207 + Logger: logger, 208 + Db: s.db, 209 + Enforcer: s.e, 210 + Engine: s.eng, 211 + Config: s.cfg, 212 + Resolver: s.res, 213 + Vault: s.vault, 214 + } 215 + 216 + return x.Router() 217 } 218 219 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
+91
spindle/xrpc/add_secret.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + ) 17 + 18 + func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger 20 + fail := func(e 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(MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoAddSecret_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(GenericError(err)) 34 + return 35 + } 36 + 37 + if err := secrets.ValidateKey(data.Key); err != nil { 38 + fail(GenericError(err)) 39 + return 40 + } 41 + 42 + // unfortunately we have to resolve repo-at here 43 + repoAt, err := syntax.ParseATURI(data.Repo) 44 + if err != nil { 45 + fail(InvalidRepoError(data.Repo)) 46 + return 47 + } 48 + 49 + // resolve this aturi to extract the repo record 50 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 + if err != nil || ident.Handle.IsInvalidHandle() { 52 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 + return 54 + } 55 + 56 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 + if err != nil { 59 + fail(GenericError(err)) 60 + return 61 + } 62 + 63 + repo := resp.Value.Val.(*tangled.Repo) 64 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 + if err != nil { 66 + fail(GenericError(err)) 67 + return 68 + } 69 + 70 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 + l.Error("insufficent permissions", "did", actorDid.String()) 72 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 + return 74 + } 75 + 76 + secret := secrets.UnlockedSecret{ 77 + Repo: secrets.DidSlashRepo(didPath), 78 + Key: data.Key, 79 + Value: data.Value, 80 + CreatedAt: time.Now(), 81 + CreatedBy: actorDid, 82 + } 83 + err = x.Vault.AddSecret(r.Context(), secret) 84 + if err != nil { 85 + l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 + writeError(w, GenericError(err), http.StatusInternalServerError) 87 + return 88 + } 89 + 90 + w.WriteHeader(http.StatusOK) 91 + }
+91
spindle/xrpc/list_secrets.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + ) 17 + 18 + func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger 20 + fail := func(e 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(MissingActorDidError) 28 + return 29 + } 30 + 31 + repoParam := r.URL.Query().Get("repo") 32 + if repoParam == "" { 33 + fail(GenericError(fmt.Errorf("empty params"))) 34 + return 35 + } 36 + 37 + // unfortunately we have to resolve repo-at here 38 + repoAt, err := syntax.ParseATURI(repoParam) 39 + if err != nil { 40 + fail(InvalidRepoError(repoParam)) 41 + return 42 + } 43 + 44 + // resolve this aturi to extract the repo record 45 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 + if err != nil || ident.Handle.IsInvalidHandle() { 47 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + return 49 + } 50 + 51 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 + if err != nil { 54 + fail(GenericError(err)) 55 + return 56 + } 57 + 58 + repo := resp.Value.Val.(*tangled.Repo) 59 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + if err != nil { 61 + fail(GenericError(err)) 62 + return 63 + } 64 + 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 + l.Error("insufficent permissions", "did", actorDid.String()) 67 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + return 69 + } 70 + 71 + ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 + if err != nil { 73 + l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 + writeError(w, GenericError(err), http.StatusInternalServerError) 75 + return 76 + } 77 + 78 + var out tangled.RepoListSecrets_Output 79 + for _, l := range ls { 80 + out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{ 81 + Repo: repoAt.String(), 82 + Key: l.Key, 83 + CreatedAt: l.CreatedAt.Format(time.RFC3339), 84 + CreatedBy: l.CreatedBy.String(), 85 + }) 86 + } 87 + 88 + w.Header().Set("Content-Type", "application/json") 89 + w.WriteHeader(http.StatusOK) 90 + json.NewEncoder(w).Encode(out) 91 + }
+82
spindle/xrpc/remove_secret.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "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/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/secrets" 15 + ) 16 + 17 + func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger 19 + fail := func(e 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(MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoRemoveSecret_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(GenericError(err)) 33 + return 34 + } 35 + 36 + // unfortunately we have to resolve repo-at here 37 + repoAt, err := syntax.ParseATURI(data.Repo) 38 + if err != nil { 39 + fail(InvalidRepoError(data.Repo)) 40 + return 41 + } 42 + 43 + // resolve this aturi to extract the repo record 44 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 + if err != nil || ident.Handle.IsInvalidHandle() { 46 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + return 48 + } 49 + 50 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 + if err != nil { 53 + fail(GenericError(err)) 54 + return 55 + } 56 + 57 + repo := resp.Value.Val.(*tangled.Repo) 58 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 + if err != nil { 60 + fail(GenericError(err)) 61 + return 62 + } 63 + 64 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 + l.Error("insufficent permissions", "did", actorDid.String()) 66 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + return 68 + } 69 + 70 + secret := secrets.Secret[any]{ 71 + Repo: secrets.DidSlashRepo(didPath), 72 + Key: data.Key, 73 + } 74 + err = x.Vault.RemoveSecret(r.Context(), secret) 75 + if err != nil { 76 + l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 + writeError(w, GenericError(err), http.StatusInternalServerError) 78 + return 79 + } 80 + 81 + w.WriteHeader(http.StatusOK) 82 + }
+147
spindle/xrpc/xrpc.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + _ "embed" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "strings" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth" 13 + "github.com/go-chi/chi/v5" 14 + 15 + "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/idresolver" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + "tangled.sh/tangled.sh/core/spindle/config" 19 + "tangled.sh/tangled.sh/core/spindle/db" 20 + "tangled.sh/tangled.sh/core/spindle/engine" 21 + "tangled.sh/tangled.sh/core/spindle/secrets" 22 + ) 23 + 24 + const ActorDid string = "ActorDid" 25 + 26 + 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 34 + } 35 + 36 + func (x *Xrpc) Router() http.Handler { 37 + r := chi.NewRouter() 38 + 39 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 + r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 + 43 + return r 44 + } 45 + 46 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 + l := x.Logger.With("url", r.URL) 49 + 50 + token := r.Header.Get("Authorization") 51 + token = strings.TrimPrefix(token, "Bearer ") 52 + 53 + s := auth.ServiceAuthValidator{ 54 + Audience: x.Config.Server.Did().String(), 55 + Dir: x.Resolver.Directory(), 56 + } 57 + 58 + did, err := s.Validate(r.Context(), token, nil) 59 + if err != nil { 60 + l.Error("signature verification failed", "err", err) 61 + writeError(w, AuthError(err), http.StatusForbidden) 62 + return 63 + } 64 + 65 + r = r.WithContext( 66 + context.WithValue(r.Context(), ActorDid, did), 67 + ) 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 + // this is slightly different from http_util::write_error to follow the spec: 141 + // 142 + // the json object returned must include an "error" and a "message" 143 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 144 + w.Header().Set("Content-Type", "application/json") 145 + w.WriteHeader(status) 146 + json.NewEncoder(w).Encode(e) 147 + }
+26
types/diff.go
··· 5 "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 8 type TextFragment struct { 9 Header string `json:"comment"` 10 Lines []gitdiff.Line `json:"lines"` ··· 77 78 return files 79 }
··· 5 "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 8 + type DiffOpts struct { 9 + Split bool `json:"split"` 10 + } 11 + 12 type TextFragment struct { 13 Header string `json:"comment"` 14 Lines []gitdiff.Line `json:"lines"` ··· 81 82 return files 83 } 84 + 85 + // used by html elements as a unique ID for hrefs 86 + func (d *Diff) Id() string { 87 + return d.Name.New 88 + } 89 + 90 + func (d *Diff) Split() *SplitDiff { 91 + fragments := make([]SplitFragment, len(d.TextFragments)) 92 + for i, fragment := range d.TextFragments { 93 + leftLines, rightLines := SeparateLines(&fragment) 94 + fragments[i] = SplitFragment{ 95 + Header: fragment.Header(), 96 + LeftLines: leftLines, 97 + RightLines: rightLines, 98 + } 99 + } 100 + 101 + return &SplitDiff{ 102 + Name: d.Id(), 103 + TextFragments: fragments, 104 + } 105 + }
+131
types/split.go
···
··· 1 + package types 2 + 3 + import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 5 + ) 6 + 7 + type SplitLine struct { 8 + LineNumber int `json:"line_number,omitempty"` 9 + Content string `json:"content"` 10 + Op gitdiff.LineOp `json:"op"` 11 + IsEmpty bool `json:"is_empty"` 12 + } 13 + 14 + type SplitFragment struct { 15 + Header string `json:"header"` 16 + LeftLines []SplitLine `json:"left_lines"` 17 + RightLines []SplitLine `json:"right_lines"` 18 + } 19 + 20 + type SplitDiff struct { 21 + Name string `json:"name"` 22 + TextFragments []SplitFragment `json:"fragments"` 23 + } 24 + 25 + // used by html elements as a unique ID for hrefs 26 + func (d *SplitDiff) Id() string { 27 + return d.Name 28 + } 29 + 30 + // separate lines into left and right, this includes additional logic to 31 + // group consecutive runs of additions and deletions in order to align them 32 + // properly in the final output 33 + // 34 + // TODO: move all diff stuff to a single package, we are spread across patchutil and types right now 35 + func SeparateLines(fragment *gitdiff.TextFragment) ([]SplitLine, []SplitLine) { 36 + lines := fragment.Lines 37 + var leftLines, rightLines []SplitLine 38 + oldLineNum := fragment.OldPosition 39 + newLineNum := fragment.NewPosition 40 + 41 + // process deletions and additions in groups for better alignment 42 + i := 0 43 + for i < len(lines) { 44 + line := lines[i] 45 + 46 + switch line.Op { 47 + case gitdiff.OpContext: 48 + leftLines = append(leftLines, SplitLine{ 49 + LineNumber: int(oldLineNum), 50 + Content: line.Line, 51 + Op: gitdiff.OpContext, 52 + IsEmpty: false, 53 + }) 54 + rightLines = append(rightLines, SplitLine{ 55 + LineNumber: int(newLineNum), 56 + Content: line.Line, 57 + Op: gitdiff.OpContext, 58 + IsEmpty: false, 59 + }) 60 + oldLineNum++ 61 + newLineNum++ 62 + i++ 63 + 64 + case gitdiff.OpDelete: 65 + deletionCount := 0 66 + for j := i; j < len(lines) && lines[j].Op == gitdiff.OpDelete; j++ { 67 + leftLines = append(leftLines, SplitLine{ 68 + LineNumber: int(oldLineNum), 69 + Content: lines[j].Line, 70 + Op: gitdiff.OpDelete, 71 + IsEmpty: false, 72 + }) 73 + oldLineNum++ 74 + deletionCount++ 75 + } 76 + i += deletionCount 77 + 78 + additionCount := 0 79 + for j := i; j < len(lines) && lines[j].Op == gitdiff.OpAdd; j++ { 80 + rightLines = append(rightLines, SplitLine{ 81 + LineNumber: int(newLineNum), 82 + Content: lines[j].Line, 83 + Op: gitdiff.OpAdd, 84 + IsEmpty: false, 85 + }) 86 + newLineNum++ 87 + additionCount++ 88 + } 89 + i += additionCount 90 + 91 + // add empty lines to balance the sides 92 + if deletionCount > additionCount { 93 + // more deletions than additions - pad right side 94 + for k := 0; k < deletionCount-additionCount; k++ { 95 + rightLines = append(rightLines, SplitLine{ 96 + Content: "", 97 + Op: gitdiff.OpContext, 98 + IsEmpty: true, 99 + }) 100 + } 101 + } else if additionCount > deletionCount { 102 + // more additions than deletions - pad left side 103 + for k := 0; k < additionCount-deletionCount; k++ { 104 + leftLines = append(leftLines, SplitLine{ 105 + Content: "", 106 + Op: gitdiff.OpContext, 107 + IsEmpty: true, 108 + }) 109 + } 110 + } 111 + 112 + case gitdiff.OpAdd: 113 + // standalone addition (not preceded by deletion) 114 + leftLines = append(leftLines, SplitLine{ 115 + Content: "", 116 + Op: gitdiff.OpContext, 117 + IsEmpty: true, 118 + }) 119 + rightLines = append(rightLines, SplitLine{ 120 + LineNumber: int(newLineNum), 121 + Content: line.Line, 122 + Op: gitdiff.OpAdd, 123 + IsEmpty: false, 124 + }) 125 + newLineNum++ 126 + i++ 127 + } 128 + } 129 + 130 + return leftLines, rightLines 131 + }