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

Compare changes

Choose any two refs to compare.

Changed files
+12500 -3263
.tangled
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
+5 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 - - event: ["push"] 2 + - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 5 dependencies: ··· 22 22 - name: build knot 23 23 command: | 24 24 go build -o knot.out ./cmd/knot 25 + 26 + - name: build spindle 27 + command: | 28 + go build -o spindle.out ./cmd/spindle
+3 -2
.tangled/workflows/fmt.yml
··· 1 1 when: 2 - - event: ["push"] 2 + - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 5 dependencies: ··· 14 14 15 15 - name: "go fmt" 16 16 command: | 17 - gofmt -l . 17 + unformatted=$(gofmt -l .) 18 + test -z "$unformatted" || (echo "$unformatted" && exit 1) 18 19
+2 -2
.tangled/workflows/test.yml
··· 1 1 when: 2 - - event: ["push"] 3 - branch: ["master", "test-ci"] 2 + - event: ["push", "pull_request"] 3 + branch: ["master"] 4 4 5 5 dependencies: 6 6 nixpkgs:
+740 -2
api/tangled/cbor_gen.go
··· 504 504 505 505 return nil 506 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 + } 507 705 func (t *FeedStar) MarshalCBOR(w io.Writer) error { 508 706 if t == nil { 509 707 _, err := w.Write(cbg.CborNull) ··· 1011 1209 } 1012 1210 1013 1211 cw := cbg.NewCborWriter(w) 1212 + fieldCount := 3 1014 1213 1015 - if _, err := cw.Write([]byte{162}); err != nil { 1214 + if t.LangBreakdown == nil { 1215 + fieldCount-- 1216 + } 1217 + 1218 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1016 1219 return err 1017 1220 } 1018 1221 ··· 1047 1250 if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1048 1251 return err 1049 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 + } 1050 1272 return nil 1051 1273 } 1052 1274 ··· 1075 1297 1076 1298 n := extra 1077 1299 1078 - nameBuf := make([]byte, 12) 1300 + nameBuf := make([]byte, 13) 1079 1301 for i := uint64(0); i < n; i++ { 1080 1302 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1081 1303 if err != nil { ··· 1128 1350 t.IsDefaultRef = true 1129 1351 default: 1130 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 + 1131 1373 } 1132 1374 1133 1375 default: ··· 1425 1667 } 1426 1668 1427 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) 1428 1968 } 1429 1969 1430 1970 default: ··· 5291 5831 } 5292 5832 } 5293 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) 5294 6032 } 5295 6033 // t.CreatedAt (string) (string) 5296 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 34 } 35 35 36 36 type GitRefUpdate_Meta struct { 37 - CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 - IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 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"` 39 40 } 40 41 41 42 type GitRefUpdate_Meta_CommitCount struct { ··· 46 47 Count int64 `json:"count" cborgen:"count"` 47 48 Email string `json:"email" cborgen:"email"` 48 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 4 5 5 // schema: sh.tangled.repo.issue.state.closed 6 6 7 - const () 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 8 10 9 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.open 6 6 7 - const () 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 8 10 9 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.closed 6 6 7 - const () 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 8 10 9 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.merged 6 6 7 - const () 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 8 10 9 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.open 6 6 7 - const () 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 8 10 9 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+18 -5
appview/config/config.go
··· 10 10 ) 11 11 12 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"` 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"` 18 19 } 19 20 20 21 type OAuthConfig struct { ··· 59 60 DB int `env:"DB, default=0"` 60 61 } 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 + 62 73 func (cfg RedisConfig) ToURL() string { 63 74 u := &url.URL{ 64 75 Scheme: "redis", ··· 84 95 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 96 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 97 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 98 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 99 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 100 } 88 101 89 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 199 unique(starred_by_did, repo_at) 200 200 ); 201 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 + 202 212 create table if not exists emails ( 203 213 id integer primary key autoincrement, 204 214 did text not null, ··· 345 355 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 346 356 347 357 -- constraints 348 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 349 358 unique (did, instance, subject) 350 359 ); 351 360 ··· 411 420 on delete cascade 412 421 ); 413 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 + 414 446 create table if not exists migrations ( 415 447 id integer primary key autoincrement, 416 448 name text unique ··· 553 585 return nil 554 586 }) 555 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 + 556 643 return &DB{db}, nil 557 644 } 558 645 ··· 628 715 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 629 716 if kind == reflect.Slice || kind == reflect.Array { 630 717 if rv.Len() == 0 { 631 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 718 + // always false 719 + return "1 = 0" 632 720 } 633 721 634 722 placeholders := make([]string, rv.Len()) ··· 647 735 kind := rv.Kind() 648 736 if kind == reflect.Slice || kind == reflect.Array { 649 737 if rv.Len() == 0 { 650 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 738 + return nil 651 739 } 652 740 653 741 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 103 query := ` 104 104 select email, did 105 105 from emails 106 - where 107 - verified = ? 106 + where 107 + verified = ? 108 108 and email in (` + strings.Join(placeholders, ",") + `) 109 109 ` 110 110 ··· 153 153 ` 154 154 var count int 155 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) 156 170 if err != nil { 157 171 return false, err 158 172 }
+2 -2
appview/db/follow.go
··· 12 12 Rkey string 13 13 } 14 14 15 - func AddFollow(e Execer, userDid, subjectDid, rkey string) error { 15 + func AddFollow(e Execer, follow *Follow) error { 16 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 - _, err := e.Exec(query, userDid, subjectDid, rkey) 17 + _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 18 18 return err 19 19 } 20 20
+17 -12
appview/db/issues.go
··· 9 9 ) 10 10 11 11 type Issue struct { 12 + ID int64 12 13 RepoAt syntax.ATURI 13 14 OwnerDid string 14 15 IssueId int ··· 65 66 66 67 issue.IssueId = nextId 67 68 68 - _, err = tx.Exec(` 69 + res, err := tx.Exec(` 69 70 insert into issues (repo_at, owner_did, issue_id, title, body) 70 71 values (?, ?, ?, ?, ?) 71 72 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 72 73 if err != nil { 73 74 return err 74 75 } 76 + 77 + lastID, err := res.LastInsertId() 78 + if err != nil { 79 + return err 80 + } 81 + issue.ID = lastID 75 82 76 83 if err := tx.Commit(); err != nil { 77 84 return err ··· 89 96 var issueAt string 90 97 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 98 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 } 99 100 100 101 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 114 115 ` 115 116 with numbered_issue as ( 116 117 select 118 + i.id, 117 119 i.owner_did, 118 120 i.issue_id, 119 121 i.created, ··· 132 134 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 135 ) 134 136 select 137 + id, 135 138 owner_did, 136 139 issue_id, 137 140 created, ··· 153 156 var issue Issue 154 157 var createdAt string 155 158 var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 159 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 160 if err != nil { 158 161 return nil, err 159 162 } ··· 182 185 183 186 rows, err := e.Query( 184 187 `select 188 + i.id, 185 189 i.owner_did, 186 190 i.repo_at, 187 191 i.issue_id, ··· 213 217 var issueCreatedAt, repoCreatedAt string 214 218 var repo Repo 215 219 err := rows.Scan( 220 + &issue.ID, 216 221 &issue.OwnerDid, 217 222 &issue.RepoAt, 218 223 &issue.IssueId, ··· 257 262 } 258 263 259 264 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 = ?` 265 + query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 261 266 row := e.QueryRow(query, repoAt, issueId) 262 267 263 268 var issue Issue 264 269 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 270 + err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 271 if err != nil { 267 272 return nil, err 268 273 } ··· 277 282 } 278 283 279 284 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 = ?` 285 + query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 281 286 row := e.QueryRow(query, repoAt, issueId) 282 287 283 288 var issue Issue 284 289 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 290 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 286 291 if err != nil { 287 292 return nil, nil, err 288 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 348 return tx.Commit() 349 349 } 350 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 + 351 459 func GetProfile(e Execer, did string) (*Profile, error) { 352 460 var profile Profile 353 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 10 ) 11 11 12 12 type Registration struct { 13 + Id int64 13 14 Domain string 14 15 ByDid string 15 16 Created *time.Time ··· 36 37 var registrations []Registration 37 38 38 39 rows, err := e.Query(` 39 - select domain, did, created, registered from registrations 40 + select id, domain, did, created, registered from registrations 40 41 where did = ? 41 42 `, did) 42 43 if err != nil { ··· 47 48 var createdAt *string 48 49 var registeredAt *string 49 50 var registration Registration 50 - err = rows.Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 51 + err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 51 52 52 53 if err != nil { 53 54 log.Println(err) ··· 75 76 var registration Registration 76 77 77 78 err := e.QueryRow(` 78 - select domain, did, created, registered from registrations 79 + select id, domain, did, created, registered from registrations 79 80 where domain = ? 80 - `, domain).Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 82 82 83 if err != nil { 83 84 if err == sql.ErrNoRows {
+68 -70
appview/db/repos.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + "log" 7 + "slices" 6 8 "strings" 7 9 "time" 8 10 ··· 71 73 return repos, nil 72 74 } 73 75 74 - func GetRepos(e Execer, filters ...filter) ([]Repo, error) { 75 - repoMap := make(map[syntax.ATURI]Repo) 76 + func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 77 + repoMap := make(map[syntax.ATURI]*Repo) 76 78 77 79 var conditions []string 78 80 var args []any ··· 86 88 whereClause = " where " + strings.Join(conditions, " and ") 87 89 } 88 90 91 + limitClause := "" 92 + if limit != 0 { 93 + limitClause = fmt.Sprintf(" limit %d", limit) 94 + } 95 + 89 96 repoQuery := fmt.Sprintf( 90 97 `select 91 98 did, ··· 98 105 spindle 99 106 from 100 107 repos r 108 + %s 109 + order by created desc 101 110 %s`, 102 111 whereClause, 112 + limitClause, 103 113 ) 104 114 rows, err := e.Query(repoQuery, args...) 105 115 ··· 139 149 repo.Spindle = spindle.String 140 150 } 141 151 142 - repoMap[repo.RepoAt()] = repo 152 + repo.RepoStats = &RepoStats{} 153 + repoMap[repo.RepoAt()] = &repo 143 154 } 144 155 145 156 if err = rows.Err(); err != nil { ··· 148 159 149 160 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 150 161 args = make([]any, len(repoMap)) 162 + 163 + i := 0 151 164 for _, r := range repoMap { 152 - args = append(args, r.RepoAt()) 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) 153 205 } 154 206 155 207 starCountQuery := fmt.Sprintf( ··· 168 220 var repoat string 169 221 var count int 170 222 if err := rows.Scan(&repoat, &count); err != nil { 223 + log.Println("err", "err", err) 171 224 continue 172 225 } 173 226 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 196 249 var repoat string 197 250 var open, closed int 198 251 if err := rows.Scan(&repoat, &open, &closed); err != nil { 252 + log.Println("err", "err", err) 199 253 continue 200 254 } 201 255 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 236 290 var repoat string 237 291 var open, merged, closed, deleted int 238 292 if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 293 + log.Println("err", "err", err) 239 294 continue 240 295 } 241 296 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 251 306 252 307 var repos []Repo 253 308 for _, r := range repoMap { 254 - repos = append(repos, r) 309 + repos = append(repos, *r) 255 310 } 256 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 + 257 319 return repos, nil 258 320 } 259 321 ··· 488 550 return &repo, nil 489 551 } 490 552 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 553 func UpdateDescription(e Execer, repoAt, newDescription string) error { 500 554 _, err := e.Exec( 501 555 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) ··· 508 562 return err 509 563 } 510 564 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 565 type RepoStats struct { 566 + Language string 569 567 StarCount int 570 568 IssueCount IssueCount 571 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 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 6 + "strings" 5 7 "time" 6 8 7 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 31 33 return nil 32 34 } 33 35 34 - func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error { 36 + func AddStar(e Execer, star *Star) error { 35 37 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 36 - _, err := e.Exec(query, starredByDid, repoAt, rkey) 38 + _, err := e.Exec( 39 + query, 40 + star.StarredByDid, 41 + star.RepoAt.String(), 42 + star.Rkey, 43 + ) 37 44 return err 38 45 } 39 46 ··· 91 98 } else { 92 99 return true 93 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 94 184 } 95 185 96 186 func GetAllStars(e Execer, limit int) ([]Star, error) {
+136 -27
appview/db/timeline.go
··· 14 14 15 15 // optional: populate only if Repo is a fork 16 16 Source *Repo 17 + 18 + // optional: populate only if event is Follow 19 + *Profile 20 + *FollowStats 17 21 } 22 + 23 + type FollowStats struct { 24 + Followers int 25 + Following int 26 + } 27 + 28 + const Limit = 50 18 29 19 30 // TODO: this gathers heterogenous events from different sources and aggregates 20 31 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 21 32 func MakeTimeline(e Execer) ([]TimelineEvent, error) { 22 33 var events []TimelineEvent 23 - limit := 50 24 34 25 - repos, err := GetAllRepos(e, limit) 35 + repos, err := getTimelineRepos(e) 26 36 if err != nil { 27 37 return nil, err 28 38 } 29 39 30 - follows, err := GetAllFollows(e, limit) 40 + stars, err := getTimelineStars(e) 31 41 if err != nil { 32 42 return nil, err 33 43 } 34 44 35 - stars, err := GetAllStars(e, limit) 45 + follows, err := getTimelineFollows(e) 36 46 if err != nil { 37 47 return nil, err 38 48 } 39 49 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 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 46 99 } 47 100 } 48 101 49 102 events = append(events, TimelineEvent{ 50 - Repo: &repo, 51 - EventAt: repo.Created, 52 - Source: sourceRepo, 103 + Repo: &r, 104 + EventAt: r.Created, 105 + Source: source, 53 106 }) 54 107 } 55 108 56 - for _, follow := range follows { 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 { 57 130 events = append(events, TimelineEvent{ 58 - Follow: &follow, 59 - EventAt: follow.FollowedAt, 131 + Star: &s, 132 + EventAt: s.Created, 60 133 }) 61 134 } 62 135 63 - for _, star := range stars { 64 - events = append(events, TimelineEvent{ 65 - Star: &star, 66 - EventAt: star.Created, 67 - }) 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 68 161 } 69 162 70 - sort.Slice(events, func(i, j int) bool { 71 - return events[i].EventAt.After(events[j].EventAt) 72 - }) 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 + } 73 174 74 - // Limit the slice to 100 events 75 - if len(events) > limit { 76 - events = events[:limit] 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 + }) 77 186 } 78 187 79 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 + }
-104
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 - }
+52 -26
appview/ingester.go
··· 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 15 "tangled.sh/tangled.sh/core/appview/config" 16 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 17 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/rbac" 20 20 ) 21 21 ··· 40 40 } 41 41 }() 42 42 43 - if e.Kind != models.EventKindCommit { 44 - return nil 45 - } 46 - 47 - switch e.Commit.Collection { 48 - case tangled.GraphFollowNSID: 49 - err = i.ingestFollow(e) 50 - case tangled.FeedStarNSID: 51 - err = i.ingestStar(e) 52 - case tangled.PublicKeyNSID: 53 - err = i.ingestPublicKey(e) 54 - case tangled.RepoArtifactNSID: 55 - err = i.ingestArtifact(e) 56 - case tangled.ActorProfileNSID: 57 - err = i.ingestProfile(e) 58 - case tangled.SpindleMemberNSID: 59 - err = i.ingestSpindleMember(e) 60 - case tangled.SpindleNSID: 61 - err = i.ingestSpindle(e) 43 + l := i.Logger.With("kind", e.Kind) 44 + switch e.Kind { 45 + case models.EventKindAccount: 46 + if !e.Account.Active && *e.Account.Status == "deactivated" { 47 + err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 48 + } 49 + case models.EventKindIdentity: 50 + err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 51 + case models.EventKindCommit: 52 + switch e.Commit.Collection { 53 + case tangled.GraphFollowNSID: 54 + err = i.ingestFollow(e) 55 + case tangled.FeedStarNSID: 56 + err = i.ingestStar(e) 57 + case tangled.PublicKeyNSID: 58 + err = i.ingestPublicKey(e) 59 + case tangled.RepoArtifactNSID: 60 + err = i.ingestArtifact(e) 61 + case tangled.ActorProfileNSID: 62 + err = i.ingestProfile(e) 63 + case tangled.SpindleMemberNSID: 64 + err = i.ingestSpindleMember(e) 65 + case tangled.SpindleNSID: 66 + err = i.ingestSpindle(e) 67 + } 68 + l = i.Logger.With("nsid", e.Commit.Collection) 62 69 } 63 70 64 71 if err != nil { 65 - l := i.Logger.With("nsid", e.Commit.Collection) 66 72 l.Error("error ingesting record", "err", err) 67 73 } 68 74 ··· 94 100 l.Error("invalid record", "err", err) 95 101 return err 96 102 } 97 - err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey) 103 + err = db.AddStar(i.Db, &db.Star{ 104 + StarredByDid: did, 105 + RepoAt: subjectUri, 106 + Rkey: e.Commit.RKey, 107 + }) 98 108 case models.CommitOperationDelete: 99 109 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 100 110 } ··· 123 133 return err 124 134 } 125 135 126 - subjectDid := record.Subject 127 - err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey) 136 + err = db.AddFollow(i.Db, &db.Follow{ 137 + UserDid: did, 138 + SubjectDid: record.Subject, 139 + Rkey: e.Commit.RKey, 140 + }) 128 141 case models.CommitOperationDelete: 129 142 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 130 143 } ··· 486 499 if err != nil || len(spindles) != 1 { 487 500 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 488 501 } 502 + spindle := spindles[0] 489 503 490 504 tx, err := ddb.Begin() 491 505 if err != nil { ··· 496 510 i.Enforcer.E.LoadPolicy() 497 511 }() 498 512 499 - err = db.DeleteSpindle( 513 + // remove spindle members first 514 + err = db.RemoveSpindleMember( 500 515 tx, 501 516 db.FilterEq("owner", did), 502 517 db.FilterEq("instance", instance), ··· 505 520 return err 506 521 } 507 522 508 - err = i.Enforcer.RemoveSpindle(instance) 523 + err = db.DeleteSpindle( 524 + tx, 525 + db.FilterEq("owner", did), 526 + db.FilterEq("instance", instance), 527 + ) 509 528 if err != nil { 510 529 return err 530 + } 531 + 532 + if spindle.Verified != nil { 533 + err = i.Enforcer.RemoveSpindle(instance) 534 + if err != nil { 535 + return err 536 + } 511 537 } 512 538 513 539 err = tx.Commit()
+32 -31
appview/issues/issues.go
··· 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 13 "github.com/bluesky-social/indigo/atproto/data" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 "github.com/go-chi/chi/v5" 16 - "github.com/posthog/posthog-go" 17 17 18 18 "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/appview" 20 19 "tangled.sh/tangled.sh/core/appview/config" 21 20 "tangled.sh/tangled.sh/core/appview/db" 22 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 + "tangled.sh/tangled.sh/core/appview/notify" 23 22 "tangled.sh/tangled.sh/core/appview/oauth" 24 23 "tangled.sh/tangled.sh/core/appview/pages" 25 24 "tangled.sh/tangled.sh/core/appview/pagination" 26 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/idresolver" 27 + "tangled.sh/tangled.sh/core/tid" 27 28 ) 28 29 29 30 type Issues struct { ··· 33 34 idResolver *idresolver.Resolver 34 35 db *db.DB 35 36 config *config.Config 36 - posthog posthog.Client 37 + notifier notify.Notifier 37 38 } 38 39 39 40 func New( ··· 43 44 idResolver *idresolver.Resolver, 44 45 db *db.DB, 45 46 config *config.Config, 46 - posthog posthog.Client, 47 + notifier notify.Notifier, 47 48 ) *Issues { 48 49 return &Issues{ 49 50 oauth: oauth, ··· 52 53 idResolver: idResolver, 53 54 db: db, 54 55 config: config, 55 - posthog: posthog, 56 + notifier: notifier, 56 57 } 57 58 } 58 59 ··· 79 80 return 80 81 } 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 + 82 94 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 83 95 if err != nil { 84 96 log.Println("failed to resolve issue owner", err) ··· 106 118 107 119 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 120 DidHandleMap: didHandleMap, 121 + 122 + OrderedReactionKinds: db.OrderedReactionKinds, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 109 125 }) 110 126 111 127 } ··· 155 171 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 172 Collection: tangled.RepoIssueStateNSID, 157 173 Repo: user.Did, 158 - Rkey: appview.TID(), 174 + Rkey: tid.TID(), 159 175 Record: &lexutil.LexiconTypeDecoder{ 160 176 Val: &tangled.RepoIssueState{ 161 177 Issue: issue.IssueAt, ··· 259 275 } 260 276 261 277 commentId := mathrand.IntN(1000000) 262 - rkey := appview.TID() 278 + rkey := tid.TID() 263 279 264 280 err := db.NewIssueComment(rp.db, &db.Comment{ 265 281 OwnerDid: user.Did, ··· 687 703 return 688 704 } 689 705 690 - err = db.NewIssue(tx, &db.Issue{ 706 + issue := &db.Issue{ 691 707 RepoAt: f.RepoAt, 692 708 Title: title, 693 709 Body: body, 694 710 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 711 } 701 - 702 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 712 + err = db.NewIssue(tx, issue) 703 713 if err != nil { 704 - log.Println("failed to get issue id", err) 714 + log.Println("failed to create issue", err) 705 715 rp.pages.Notice(w, "issues", "Failed to create issue.") 706 716 return 707 717 } ··· 716 726 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 717 727 Collection: tangled.RepoIssueNSID, 718 728 Repo: user.Did, 719 - Rkey: appview.TID(), 729 + Rkey: tid.TID(), 720 730 Record: &lexutil.LexiconTypeDecoder{ 721 731 Val: &tangled.RepoIssue{ 722 732 Repo: atUri, 723 733 Title: title, 724 734 Body: &body, 725 735 Owner: user.Did, 726 - IssueId: int64(issueId), 736 + IssueId: int64(issue.IssueId), 727 737 }, 728 738 }, 729 739 }) ··· 733 743 return 734 744 } 735 745 736 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 746 + err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 737 747 if err != nil { 738 748 log.Println("failed to set issue at", err) 739 749 rp.pages.Notice(w, "issues", "Failed to create issue.") 740 750 return 741 751 } 742 752 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 + rp.notifier.NewIssue(r.Context(), issue) 753 754 754 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 755 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 755 756 return 756 757 } 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 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 15 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 16 "tangled.sh/tangled.sh/core/appview/oauth" 18 17 "tangled.sh/tangled.sh/core/appview/pages" 19 18 "tangled.sh/tangled.sh/core/appview/pagination" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/rbac" 22 22 ) 23 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 16 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 17 "tangled.sh/tangled.sh/core/appview/config" 18 18 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 19 "tangled.sh/tangled.sh/core/appview/middleware" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/rbac" 26 26 )
+73
appview/oauth/oauth.go
··· 7 7 "net/url" 8 8 "time" 9 9 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 10 11 "github.com/gorilla/sessions" 11 12 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 13 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 204 205 }) 205 206 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 207 280 } 208 281 209 282 type ClientMetadata struct {
+67 -31
appview/pages/funcmap.go
··· 17 17 "time" 18 18 19 19 "github.com/dustin/go-humanize" 20 + "github.com/go-enry/go-enry/v2" 20 21 "github.com/microcosm-cc/bluemonday" 21 22 "tangled.sh/tangled.sh/core/appview/filetree" 22 23 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 105 106 s = append(s, values...) 106 107 return s 107 108 }, 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 { 109 + "commaFmt": humanize.Comma, 110 + "relTimeFmt": humanize.Time, 111 + "shortRelTimeFmt": func(t time.Time) string { 114 112 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 115 113 {time.Second, "now", time.Second}, 116 114 {2 * time.Second, "1s %s", 1}, ··· 129 127 {math.MaxInt64, "a long while %s", 1}, 130 128 }) 131 129 }, 132 - "durationFmt": func(duration time.Duration) string { 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 { 133 137 days := int64(duration.Hours() / 24) 134 138 hours := int64(math.Mod(duration.Hours(), 24)) 135 139 minutes := int64(math.Mod(duration.Minutes(), 60)) 136 140 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, " ") 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"}) 157 148 }, 158 149 "byteFmt": humanize.Bytes, 159 150 "length": func(slice any) int { ··· 200 191 if v.Len() == 0 { 201 192 return nil 202 193 } 203 - return v.Slice(0, min(n, v.Len()-1)).Interface() 194 + return v.Slice(0, min(n, v.Len())).Interface() 204 195 }, 205 196 206 197 "markdown": func(text string) template.HTML { ··· 250 241 return u 251 242 }, 252 243 253 - "tinyAvatar": p.tinyAvatar, 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 + }, 254 257 } 255 258 } 256 259 257 - func (p *Pages) tinyAvatar(handle string) string { 260 + func (p *Pages) avatarUri(handle, size string) string { 258 261 handle = strings.TrimPrefix(handle, "@") 262 + 259 263 secret := p.avatar.SharedSecret 260 264 h := hmac.New(sha256.New, []byte(secret)) 261 265 h.Write([]byte(handle)) 262 266 signature := hex.EncodeToString(h.Sum(nil)) 263 - return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle) 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) 264 273 } 265 274 266 275 func icon(name string, classes []string) (template.HTML, error) { ··· 288 297 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 289 298 return template.HTML(modifiedSVG), nil 290 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 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+157 -26
appview/pages/pages.go
··· 14 14 "os" 15 15 "path/filepath" 16 16 "strings" 17 + "sync" 17 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 18 20 "tangled.sh/tangled.sh/core/appview/commitverify" 19 21 "tangled.sh/tangled.sh/core/appview/config" 20 22 "tangled.sh/tangled.sh/core/appview/db" ··· 32 34 "github.com/bluesky-social/indigo/atproto/syntax" 33 35 "github.com/go-git/go-git/v5/plumbing" 34 36 "github.com/go-git/go-git/v5/plumbing/object" 35 - "github.com/microcosm-cc/bluemonday" 36 37 ) 37 38 38 39 //go:embed templates/* static 39 40 var Files embed.FS 40 41 41 42 type Pages struct { 42 - t map[string]*template.Template 43 + mu sync.RWMutex 44 + t map[string]*template.Template 45 + 43 46 avatar config.AvatarConfig 44 47 dev bool 45 48 embedFS embed.FS ··· 56 59 } 57 60 58 61 p := &Pages{ 62 + mu: sync.RWMutex{}, 59 63 t: make(map[string]*template.Template), 60 64 dev: config.Core.Dev, 61 65 avatar: config.Avatar, ··· 147 151 } 148 152 149 153 log.Printf("total templates loaded: %d", len(templates)) 154 + p.mu.Lock() 155 + defer p.mu.Unlock() 150 156 p.t = templates 151 157 } 152 158 ··· 207 213 } 208 214 209 215 // Update the template in the map 216 + p.mu.Lock() 217 + defer p.mu.Unlock() 210 218 p.t[name] = tmpl 211 219 log.Printf("template reloaded from disk: %s", name) 212 220 return nil ··· 221 229 } 222 230 } 223 231 232 + p.mu.RLock() 233 + defer p.mu.RUnlock() 224 234 tmpl, exists := p.t[templateName] 225 235 if !exists { 226 236 return fmt.Errorf("template not found: %s", templateName) ··· 252 262 return p.executePlain("user/login", w, params) 253 263 } 254 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 + 255 287 type TimelineParams struct { 256 288 LoggedInUser *oauth.User 257 289 Timeline []db.TimelineEvent ··· 278 310 } 279 311 280 312 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 281 - return p.execute("knots", w, params) 313 + return p.execute("knots/index", w, params) 282 314 } 283 315 284 316 type KnotParams struct { ··· 286 318 DidHandleMap map[string]string 287 319 Registration *db.Registration 288 320 Members []string 321 + Repos map[string][]db.Repo 289 322 IsOwner bool 290 323 } 291 324 292 325 func (p *Pages) Knot(w io.Writer, params KnotParams) error { 293 - return p.execute("knot", w, params) 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) 294 351 } 295 352 296 353 type SpindlesParams struct { ··· 413 470 return p.executePlain("user/fragments/editPins", w, params) 414 471 } 415 472 416 - type RepoActionsFragmentParams struct { 473 + type RepoStarFragmentParams struct { 417 474 IsStarred bool 418 475 RepoAt syntax.ATURI 419 476 Stats db.RepoStats 420 477 } 421 478 422 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 423 - return p.executePlain("repo/fragments/repoActions", w, params) 479 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 480 + return p.executePlain("repo/fragments/repoStar", w, params) 424 481 } 425 482 426 483 type RepoDescriptionParams struct { ··· 467 524 ext := filepath.Ext(params.ReadmeFileName) 468 525 switch ext { 469 526 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 527 + htmlString = p.rctx.Sanitize(htmlString) 470 528 htmlString = p.rctx.RenderMarkdown(params.Readme) 471 529 params.Raw = false 472 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 530 + params.HTMLReadme = template.HTML(htmlString) 473 531 default: 474 - htmlString = string(params.Readme) 475 532 params.Raw = true 476 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 477 533 } 478 534 } 479 535 ··· 502 558 Active string 503 559 EmailToDidOrHandle map[string]string 504 560 Pipeline *db.Pipeline 561 + DiffOpts types.DiffOpts 505 562 506 563 // singular because it's always going to be just one 507 564 VerifiedCommit commitverify.VerifiedCommits ··· 519 576 RepoInfo repoinfo.RepoInfo 520 577 Active string 521 578 BreadCrumbs [][]string 522 - BaseTreeLink string 523 - BaseBlobLink string 579 + TreePath string 524 580 types.RepoTreeResponse 525 581 } 526 582 ··· 590 646 LoggedInUser *oauth.User 591 647 RepoInfo repoinfo.RepoInfo 592 648 Active string 649 + Unsupported bool 650 + IsImage bool 651 + IsVideo bool 652 + ContentSrc string 593 653 BreadCrumbs [][]string 594 654 ShowRendered bool 595 655 RenderToggle bool ··· 657 717 Branches []types.Branch 658 718 Spindles []string 659 719 CurrentSpindle string 720 + Secrets []*tangled.RepoListSecrets_Secret 721 + 660 722 // TODO: use repoinfo.roles 661 723 IsCollaboratorInviteAllowed bool 662 724 } ··· 666 728 return p.executeRepo("repo/settings", w, params) 667 729 } 668 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 + 669 775 type RepoIssuesParams struct { 670 776 LoggedInUser *oauth.User 671 777 RepoInfo repoinfo.RepoInfo ··· 690 796 IssueOwnerHandle string 691 797 DidHandleMap map[string]string 692 798 799 + OrderedReactionKinds []db.ReactionKind 800 + Reactions map[db.ReactionKind]int 801 + UserReacted map[db.ReactionKind]bool 802 + 693 803 State string 694 804 } 695 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 + 696 817 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 697 818 params.Active = "issues" 698 819 if params.Issue.Open { ··· 762 883 DidHandleMap map[string]string 763 884 FilteringBy db.PullState 764 885 Stacks map[string]db.Stack 886 + Pipelines map[string]db.Pipeline 765 887 } 766 888 767 889 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 798 920 MergeCheck types.MergeCheckResponse 799 921 ResubmitCheck ResubmitResult 800 922 Pipelines map[string]db.Pipeline 923 + 924 + OrderedReactionKinds []db.ReactionKind 925 + Reactions map[db.ReactionKind]int 926 + UserReacted map[db.ReactionKind]bool 801 927 } 802 928 803 929 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 806 932 } 807 933 808 934 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 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 817 945 } 818 946 819 947 // this name is a mouthful ··· 822 950 } 823 951 824 952 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 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 831 961 } 832 962 833 963 // this name is a mouthful ··· 918 1048 Base string 919 1049 Head string 920 1050 Diff *types.NiceDiff 1051 + DiffOpts types.DiffOpts 921 1052 922 1053 Active string 923 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 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 15 {{ block "extrameta" . }}{{ end }} 16 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" . }} 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;"> 21 20 {{ template "layouts/topbar" . }} 22 - {{ end }} 23 21 </header> 24 - <main class="content grow">{{ block "content" . }}{{ end }}</main> 25 - <footer class="mt-16"> 26 - {{ block "footer" . }} 27 - {{ template "layouts/footer" . }} 28 - {{ end }} 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" . }} 29 55 </footer> 30 - </div> 56 + {{ end }} 31 57 </body> 32 58 </html> 33 59 {{ end }}
+41 -3
appview/pages/templates/layouts/footer.html
··· 1 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> 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> 5 43 </div> 6 44 </div> 7 45 {{ end }}
+26 -4
appview/pages/templates/layouts/repobase.html
··· 19 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 20 </div> 21 21 22 - {{ template "repo/fragments/repoActions" .RepoInfo }} 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> 23 45 </div> 24 46 {{ template "repo/fragments/repoDescription" . }} 25 47 </section> 26 48 27 49 <section 28 - class="min-h-screen w-full flex flex-col drop-shadow-sm" 50 + class="w-full flex flex-col drop-shadow-sm" 29 51 > 30 52 <nav class="w-full pl-4 overflow-auto"> 31 53 <div class="flex z-60"> ··· 47 69 {{ if eq $.Active $key }} 48 70 {{ $activeTabStyles }} 49 71 {{ else }} 50 - group-hover:bg-gray-200 dark:group-hover:bg-gray-700 72 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 73 {{ end }} 52 74 " 53 75 > ··· 64 86 </div> 65 87 </nav> 66 88 <section 67 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white" 89 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 68 90 > 69 91 {{ block "repoContent" . }}{{ end }} 70 92 </section>
+7 -17
appview/pages/templates/layouts/topbar.html
··· 1 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"> 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 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 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 9 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 10 <div id="right-items" class="flex items-center gap-4"> 23 11 {{ with .LoggedInUser }} 24 12 <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> ··· 36 24 {{ define "dropDown" }} 37 25 <details class="relative inline-block text-left"> 38 26 <summary 39 - class="cursor-pointer list-none" 27 + class="cursor-pointer list-none flex items-center" 40 28 > 41 - {{ didOrHandle .Did .Handle }} 29 + {{ $user := didOrHandle .Did .Handle }} 30 + {{ template "user/fragments/picHandle" $user }} 42 31 </summary> 43 32 <div 44 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" 45 34 > 46 - <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 35 + <a href="/{{ $user }}">profile</a> 36 + <a href="/{{ $user }}?tab=repos">repositories</a> 47 37 <a href="/knots">knots</a> 48 38 <a href="/spindles">spindles</a> 49 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 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 10 + 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} ··· 44 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 45 {{ if .RenderToggle }} 46 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 49 hx-boost="true" 50 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 51 {{ end }} 52 52 </div> 53 53 </div> 54 54 </div> 55 - {{ if .IsBinary }} 55 + {{ if and .IsBinary .Unsupported }} 56 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 57 + Previews are not supported for this file type. 58 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> 59 72 {{ else }} 60 73 <div class="overflow-auto relative"> 61 74 {{ if .ShowRendered }}
+2 -2
appview/pages/templates/repo/branches.html
··· 59 59 </td> 60 60 <td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400"> 61 61 {{ if .Commit }} 62 - {{ .Commit.Committer.When | timeFmt }} 62 + {{ template "repo/fragments/time" .Commit.Committer.When }} 63 63 {{ end }} 64 64 </td> 65 65 </tr> ··· 98 98 </a> 99 99 </span> 100 100 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 101 - <span>{{ .Commit.Committer.When | timeFmt }}</span> 101 + {{ template "repo/fragments/time" .Commit.Committer.When }} 102 102 </div> 103 103 {{ end }} 104 104 </div>
+43 -6
appview/pages/templates/repo/commit.html
··· 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 35 {{ end }} 36 36 <span class="px-1 select-none before:content-['\00B7']"></span> 37 - {{ timeFmt $commit.Author.When }} 37 + {{ template "repo/fragments/time" $commit.Author.When }} 38 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 39 </p> 40 40 ··· 59 59 <div class="flex items-center gap-2 my-2"> 60 60 {{ i "user" "w-4 h-4" }} 61 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a> 62 + <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 63 </div> 64 64 <div class="my-1 pt-2 text-xs border-t"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 77 77 </div> 78 78 79 79 </section> 80 + {{end}} 80 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) }} 81 115 {{end}} 82 116 83 - {{ define "repoAfter" }} 84 - <div class="-z-[9999]"> 85 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 86 - </div> 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> 87 124 {{end}}
+42 -2
appview/pages/templates/repo/compare/compare.html
··· 10 10 {{ end }} 11 11 {{ end }} 12 12 13 - {{ define "repoAfter" }} 14 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 13 + {{ define "topbarLayout" }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 15 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 19 <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 20 20 <div class="flex items-center justify-between p-2"> 21 21 {{ $br.Name }} 22 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 22 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 23 23 </div> 24 24 </a> 25 25 {{ end }}
+16 -4
appview/pages/templates/repo/empty.html
··· 17 17 <a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline"> 18 18 <div class="flex items-center justify-between p-2"> 19 19 {{ $br.Name }} 20 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 20 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 21 21 </div> 22 22 </a> 23 23 {{ end }} 24 24 </div> 25 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> 26 40 {{ 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> 41 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 30 42 {{ end }} 31 43 </main> 32 44 {{ end }}
+2 -2
appview/pages/templates/repo/fragments/artifact.html
··· 10 10 </div> 11 11 12 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> 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 15 16 16 <span class="select-none after:content-['ยท'] hidden md:inline"></span> 17 17 <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
+90 -145
appview/pages/templates/repo/fragments/diff.html
··· 1 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 }} 2 + {{ $repo := index . 0 }} 3 + {{ $diff := index . 1 }} 4 + {{ $opts := index . 2 }} 8 5 6 + {{ $commit := $diff.Commit }} 7 + {{ $diff := $diff.Diff }} 8 + {{ $isSplit := $opts.Split }} 9 9 {{ $this := $commit.This }} 10 10 {{ $parent := $commit.Parent }} 11 + {{ $last := sub (len $diff) 1 }} 11 12 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> 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 }} 21 36 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 }} 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> 45 74 46 - {{ block "statPill" .Stats }} {{ end }} 47 75 </div> 76 + </summary> 48 77 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> 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 -}} 62 94 {{ 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> 95 + {{- template "repo/fragments/unifiedDiff" . -}} 66 96 {{ end }} 67 - </div> 97 + {{- end -}} 68 98 </div> 69 99 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 }} 100 + </details> 77 101 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 102 </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> 103 + </div> 104 + </section> 105 + {{ end }} 161 106 {{ end }} 162 107 </div> 163 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 1 {{ define "repo/fragments/interdiff" }} 2 2 {{ $repo := index . 0 }} 3 3 {{ $x := index . 1 }} 4 + {{ $opts := index . 2 }} 4 5 {{ $fileTree := fileTree $x.AffectedFiles }} 5 6 {{ $diff := $x.Files }} 7 + {{ $last := sub (len $diff) 1 }} 8 + {{ $isSplit := $opts.Split }} 6 9 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 }} 10 + <div class="flex flex-col gap-4"> 17 11 {{ 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 }} 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> 41 42 </div> 42 43 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> 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 }} 47 56 </div> 57 + 48 58 </div> 59 + </summary> 49 60 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> 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" . -}} 56 79 {{ 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 - 80 + {{- end -}} 64 81 </div> 65 - </summary> 66 82 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> 83 + </details> 122 84 123 - </details> 124 - 85 + </div> 125 86 </div> 126 - </div> 127 - </section> 128 - {{ end }} 87 + </section> 88 + {{ end }} 129 89 {{ end }} 90 + </div> 130 91 {{ end }} 131 92 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 }}
+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 127 {{ end }} 128 128 129 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" }} 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" }} 136 132 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> 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" }} 150 139 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> 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> 173 151 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> 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> 184 160 {{ end }} 185 161 186 162 {{ define "rightInfo" }} ··· 194 170 {{ define "commitLog" }} 195 171 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 196 172 <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> 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> 204 176 </a> 205 177 </div> 206 178 <div class="flex flex-col gap-6"> ··· 266 238 {{ end }}" 267 239 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 268 240 >{{ if $didOrHandle }} 269 - {{ $didOrHandle }} 241 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 270 242 {{ else }} 271 243 {{ .Author.Name }} 272 244 {{ end }}</a 273 245 > 274 246 </span> 275 247 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 276 - <span>{{ timeFmt .Committer.When }}</span> 248 + {{ template "repo/fragments/time" .Committer.When }} 277 249 278 250 <!-- tags/branches --> 279 251 {{ $tagsForCommit := index $.TagMap .Hash.String }} ··· 302 274 {{ define "branchList" }} 303 275 {{ if gt (len .BranchesTrunc) 0 }} 304 276 <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> 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> 312 280 </a> 313 281 <div class="flex flex-col gap-1"> 314 282 {{ range .BranchesTrunc }} ··· 320 288 </a> 321 289 {{ if .Commit }} 322 290 <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> 291 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 324 292 {{ end }} 325 293 {{ if .IsDefault }} 326 294 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> ··· 345 313 {{ if gt (len .TagsTrunc) 0 }} 346 314 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 347 315 <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> 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> 355 319 </a> 356 320 </div> 357 321 <div class="flex flex-col gap-1"> ··· 366 330 </div> 367 331 <div> 368 332 {{ with .Tag }} 369 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time> 333 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span> 370 334 {{ end }} 371 335 {{ if eq $idx 0 }} 372 336 {{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }} ··· 382 346 {{ end }} 383 347 384 348 {{ define "repoAfter" }} 385 - {{- if .HTMLReadme -}} 349 + {{- if or .HTMLReadme .Readme -}} 386 350 <section 387 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 }} 388 352 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 390 354 dark:[&_pre]:border dark:[&_pre]:border-gray-700 391 355 {{ end }}" 392 356 > 393 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll"> 394 - {{- .HTMLReadme -}} 357 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 + {{- .Readme -}} 395 359 </pre> 396 360 {{- else -}} 397 361 {{ .HTMLReadme }}
+3 -5
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 7 ··· 9 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 10 {{ if $isIssueAuthor }} 11 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 12 author 14 - </span> 15 13 {{ end }} 16 14 17 15 <span class="before:content-['ยท']"></span> 18 16 <a 19 17 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 21 19 id="{{ .CommentId }}"> 22 - {{ .Created | timeFmt }} 20 + {{ template "repo/fragments/time" .Created }} 23 21 </a> 24 22 25 23 <button
+15 -16
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 1 {{ define "repo/issues/fragments/issueComment" }} 2 2 {{ with .Comment }} 3 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"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 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 }} 7 14 8 15 <span class="before:content-['ยท']"></span> 9 16 <a ··· 11 18 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 12 19 id="{{ .CommentId }}"> 13 20 {{ if .Deleted }} 14 - deleted {{ .Deleted | timeFmt }} 21 + deleted {{ template "repo/fragments/time" .Deleted }} 15 22 {{ else if .Edited }} 16 - edited {{ .Edited | timeFmt }} 23 + edited {{ template "repo/fragments/time" .Edited }} 17 24 {{ else }} 18 - {{ .Created | timeFmt }} 25 + {{ template "repo/fragments/time" .Created }} 19 26 {{ end }} 20 27 </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 28 30 29 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 30 {{ if and $isCommentOwner (not .Deleted) }} 32 - <button 33 - class="btn px-2 py-1 text-sm" 31 + <button 32 + class="btn px-2 py-1 text-sm" 34 33 hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 35 34 hx-swap="outerHTML" 36 35 hx-target="#comment-container-{{.CommentId}}" 37 36 > 38 37 {{ i "pencil" "w-4 h-4" }} 39 38 </button> 40 - <button 39 + <button 41 40 class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 42 41 hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 43 42 hx-confirm="Are you sure you want to delete your comment?"
+17 -5
appview/pages/templates/repo/issues/issue.html
··· 33 33 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 34 opened by 35 35 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandle" $owner }} 36 + {{ template "user/fragments/picHandleLink" $owner }} 37 37 <span class="select-none before:content-['\00B7']"></span> 38 - <time title="{{ .Issue.Created | longTimeFmt }}"> 39 - {{ .Issue.Created | timeFmt }} 40 - </time> 38 + {{ template "repo/fragments/time" .Issue.Created }} 41 39 </span> 42 40 </div> 43 41 ··· 46 44 {{ .Issue.Body | markdown }} 47 45 </article> 48 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> 49 61 </section> 50 62 {{ end }} 51 63 ··· 76 88 > 77 89 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 78 90 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 79 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 91 + {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 80 92 </div> 81 93 <textarea 82 94 id="comment-textarea"
+2 -4
appview/pages/templates/repo/issues/issues.html
··· 66 66 67 67 <span class="ml-1"> 68 68 {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandle" $owner }} 69 + {{ template "user/fragments/picHandleLink" $owner }} 70 70 </span> 71 71 72 72 <span class="before:content-['ยท']"> 73 - <time> 74 - {{ .Created | timeFmt }} 75 - </time> 73 + {{ template "repo/fragments/time" .Created }} 76 74 </span> 77 75 78 76 <span class="before:content-['ยท']">
+76 -79
appview/pages/templates/repo/log.html
··· 14 14 </h2> 15 15 16 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> 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> 61 59 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 }} 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 }} 69 67 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> 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> 78 76 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> 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> 95 92 96 93 <!-- mobile view (visible only on small screens) --> 97 94 <div class="md:hidden"> ··· 102 99 <div class="text-base cursor-pointer"> 103 100 <div class="flex items-center justify-between"> 104 101 <div class="flex-1"> 105 - <div class="inline-flex items-end"> 102 + <div> 106 103 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 107 104 class="inline no-underline hover:underline dark:text-white"> 108 105 {{ index $messageParts 0 }} 109 106 </a> 110 107 {{ if gt (len $messageParts) 1 }} 111 108 <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" 109 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 113 110 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 114 111 {{ i "ellipsis" "w-3 h-3" }} 115 112 </button> ··· 159 156 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 160 157 <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 161 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 162 - {{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 159 + {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 163 160 </a> 164 161 </span> 165 162 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 166 - <span>{{ shortTimeFmt $commit.Committer.When }}</span> 163 + <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 167 164 168 165 <!-- ci status --> 169 166 {{ $pipeline := index $.Pipelines .Hash.String }}
+3 -3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 1 1 {{ define "repo/pipelines/fragments/logBlock" }} 2 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"> 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 5 <div class="group-open:hidden flex items-center gap-1"> 6 6 {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 7 </div> ··· 9 9 {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 10 </div> 11 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> 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 13 </details> 14 14 </div> 15 15 {{ end }}
+5 -9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
··· 10 10 {{ $lastStatus := $all.Latest }} 11 11 {{ $kind := $lastStatus.Status.String }} 12 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 13 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 22 14 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 23 15 {{ $name }} 24 16 </div> 25 17 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 26 18 <span class="font-bold">{{ $kind }}</span> 27 - <time>{{ $time }}</time> 19 + {{ if .TimeTaken }} 20 + {{ template "repo/fragments/duration" .TimeTaken }} 21 + {{ else }} 22 + {{ template "repo/fragments/shortTimeAgo" $pipeline.Created }} 23 + {{ end }} 28 24 </div> 29 25 </div> 30 26 </a>
+1 -3
appview/pages/templates/repo/pipelines/pipelines.html
··· 74 74 </div> 75 75 76 76 <div class="text-sm md:text-base col-span-1 text-right"> 77 - <time title="{{ .Created | longTimeFmt }}"> 78 - {{ .Created | shortTimeFmt }} ago 79 - </time> 77 + {{ template "repo/fragments/shortTimeAgo" .Created }} 80 78 </div> 81 79 82 80 {{ $t := .TimeTaken }}
+5 -13
appview/pages/templates/repo/pipelines/workflow.html
··· 17 17 </section> 18 18 {{ end }} 19 19 20 - {{ define "repoAfter" }} 21 - {{ end }} 22 - 23 20 {{ define "sidebar" }} 24 21 {{ $active := .Workflow }} 25 22 {{ with .Pipeline }} ··· 32 29 {{ $lastStatus := $all.Latest }} 33 30 {{ $kind := $lastStatus.Status.String }} 34 31 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 32 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 45 33 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 46 34 {{ $name }} 47 35 </div> 48 36 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 49 37 <span class="font-bold">{{ $kind }}</span> 50 - <time>{{ $time }}</time> 38 + {{ if .TimeTaken }} 39 + {{ template "repo/fragments/duration" .TimeTaken }} 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 42 + {{ end }} 51 43 </div> 52 44 </div> 53 45 </a>
+18 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 29 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 30 opened by 31 31 {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandle" $owner }} 32 + {{ template "user/fragments/picHandleLink" $owner }} 33 33 <span class="select-none before:content-['\00B7']"></span> 34 - <time>{{ .Pull.Created | timeFmt }}</time> 34 + {{ template "repo/fragments/time" .Pull.Created }} 35 35 36 36 <span class="select-none before:content-['\00B7']"></span> 37 37 <span> ··· 60 60 <article id="body" class="mt-8 prose dark:prose-invert"> 61 61 {{ .Pull.Body | markdown }} 62 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> 63 79 {{ end }} 64 80 </section> 65 81
+2 -3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 6 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} ··· 38 38 </form> 39 39 </div> 40 40 {{ end }} 41 -
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 1 1 {{ define "repo/pulls/fragments/pullStack" }} 2 - 3 2 <details class="bg-white dark:bg-gray-800 group" open> 4 3 <summary class="p-2 text-sm font-bold list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 5 4 <span class="flex items-center gap-2">
+6 -8
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 13 13 </span> 14 14 </div> 15 15 16 - <div class="flex-shrink-0 flex items-center"> 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 17 {{ $latestRound := .LastRoundNumber }} 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 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> 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> 25 23 {{ end }} 26 24 <span> 27 - <div class="inline-flex items-center gap-2"> 25 + <div class="inline-flex items-center gap-1"> 28 26 {{ i "message-square" "w-3 h-3 md:hidden" }} 29 27 {{ $commentCount }} 30 28 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 31 29 </div> 32 30 </span> 33 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 34 32 <span> 35 33 <span class="hidden md:inline">round</span> 36 34 <span class="font-mono">#{{ $latestRound }}</span>
+44 -3
appview/pages/templates/repo/pulls/interdiff.html
··· 26 26 </header> 27 27 </section> 28 28 29 - <section> 30 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 31 - </section> 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> 32 54 {{ end }} 33 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 31 <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 32 32 {{ template "repo/pulls/fragments/pullHeader" . }} 33 33 </section> 34 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 35 34 </section> 36 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 5 {{ define "extrameta" }} 6 6 {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 ··· 46 46 </div> 47 47 <!-- round summary --> 48 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 - <span> 49 + <span class="gap-1 flex items-center"> 50 50 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by <a href="/{{ $owner }}">{{ $owner }}</a> 56 + by {{ template "user/fragments/picHandleLink" $owner }} 57 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> 58 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 59 <span class="select-none before:content-['ยท']"></span> 60 60 {{ $s := "s" }} 61 61 {{ if eq (len .Comments) 1 }} ··· 68 68 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 69 69 hx-boost="true" 70 70 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 71 - {{ i "file-diff" "w-4 h-4" }} 71 + {{ i "file-diff" "w-4 h-4" }} 72 72 <span class="hidden md:inline">diff</span> 73 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 74 </a> ··· 150 150 {{ if gt $cidx 0 }} 151 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 152 {{ end }} 153 - <div class="text-sm text-gray-500 dark:text-gray-400"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - <a href="/{{$owner}}">{{$owner}}</a> 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 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> 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 158 </div> 159 159 <div class="prose dark:prose-invert"> 160 160 {{ $c.Body | markdown }} ··· 179 179 {{ end }} 180 180 </div> 181 181 </details> 182 - <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 183 182 {{ end }} 184 183 {{ end }} 185 184 {{ end }} ··· 277 276 {{ $lastStatus := $all.Latest }} 278 277 {{ $kind := $lastStatus.Status.String }} 279 278 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 279 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 290 280 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 291 281 {{ $name }} 292 282 </div> 293 283 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 294 284 <span class="font-bold">{{ $kind }}</span> 295 - <time>{{ $time }}</time> 285 + {{ if .TimeTaken }} 286 + {{ template "repo/fragments/duration" .TimeTaken }} 287 + {{ else }} 288 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 289 + {{ end }} 296 290 </div> 297 291 </div> 298 292 </a>
+46 -57
appview/pages/templates/repo/pulls/pulls.html
··· 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 58 {{ $owner := index $.DidHandleMap .OwnerDid }} 59 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 60 {{ $icon := "ban" }} ··· 76 76 </span> 77 77 78 78 <span class="ml-1"> 79 - {{ template "user/fragments/picHandle" $owner }} 79 + {{ template "user/fragments/picHandleLink" $owner }} 80 80 </span> 81 81 82 - <span> 83 - <time> 84 - {{ .Created | timeFmt }} 85 - </time> 82 + <span class="before:content-['ยท']"> 83 + {{ template "repo/fragments/time" .Created }} 86 84 </span> 85 + 86 + 87 + {{ $latestRound := .LastRoundNumber }} 88 + {{ $lastSubmission := index .Submissions $latestRound }} 87 89 88 90 <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> 91 + {{ $commentCount := len $lastSubmission.Comments }} 92 + {{ $s := "s" }} 93 + {{ if eq $commentCount 1 }} 94 + {{ $s = "" }} 95 + {{ end }} 96 + 97 + {{ len $lastSubmission.Comments}} comment{{$s}} 93 98 </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 -}} 99 + 100 + <span class="before:content-['ยท']"> 101 + round 102 + <span class="font-mono"> 103 + #{{ .LastRoundNumber }} 104 + </span> 105 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 }} 106 111 {{ 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> 112 + </div> 127 113 </div> 128 114 {{ if .StackId }} 129 115 {{ $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> 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 }} 145 133 {{ end }} 146 134 </div> 147 135 {{ end }} ··· 153 141 {{ $root := index . 1 }} 154 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"> 155 143 {{ range $pull := $list }} 144 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 156 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"> 157 146 <div class="flex gap-2 items-center px-6"> 158 147 <div class="flex-grow min-w-0 w-full py-2"> 159 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 148 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 160 149 </div> 161 150 </div> 162 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 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 2 3 {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 4 + {{ template "collaboratorSettings" . }} 5 + {{ template "branchSettings" . }} 6 + {{ template "dangerZone" . }} 7 + {{ template "spindleSelector" . }} 8 + {{ template "spindleSecrets" . }} 9 + {{ end }} 6 10 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> 11 + {{ define "collaboratorSettings" }} 12 + <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 + Collaborators 14 + </header> 24 15 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 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" 29 22 > 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> 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> 49 31 {{ end }} 32 + </div> 50 33 34 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 51 35 <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 36 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 + class="group" 54 38 > 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> 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> 115 53 </form> 116 - {{ end }} 54 + {{ end }} 55 + {{ end }} 117 56 118 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 57 + {{ define "dangerZone" }} 58 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 119 59 <form 120 60 hx-confirm="Are you sure you want to delete this repository?" 121 61 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 122 62 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> 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> 135 74 </form> 136 - {{ end }} 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" /> 137 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 }} 138 168 {{ end }}
+2 -2
appview/pages/templates/repo/tags.html
··· 35 35 <span>{{ .Tag.Tagger.Name }}</span> 36 36 37 37 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 - <time>{{ shortTimeFmt .Tag.Tagger.When }}</time> 38 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 39 {{ end }} 40 40 </div> 41 41 </div> ··· 54 54 {{ slice .Tag.Target.String 0 8 }} 55 55 </a> 56 56 <span>{{ .Tag.Tagger.Name }}</span> 57 - <time>{{ timeFmt .Tag.Tagger.When }}</time> 57 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 58 {{ end }} 59 59 </div> 60 60 </div>
+28 -30
appview/pages/templates/repo/tree.html
··· 11 11 {{ template "repo/fragments/meta" . }} 12 12 {{ $title := printf "%s at %s &middot; %s" $path .Ref .RepoInfo.FullName }} 13 13 {{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 - 14 + 15 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 16 {{ end }} 17 17 ··· 19 19 {{define "repoContent"}} 20 20 <main> 21 21 <div class="tree"> 22 - {{ $containerstyle := "py-1" }} 23 22 {{ $linkstyle := "no-underline hover:underline" }} 24 23 25 24 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 54 53 </div> 55 54 56 55 {{ 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 }} 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> 68 71 </div> 69 - </div> 70 - {{ end }} 71 - {{ end }} 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> 72 78 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 }} 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 }} 85 83 </div> 86 - </div> 84 + </div> 87 85 {{ end }} 88 - {{ end }} 86 + 89 87 </div> 90 88 </main> 91 89 {{end}}
+2 -2
appview/pages/templates/settings.html
··· 39 39 {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 40 <p class="font-bold dark:text-white">{{ .Name }}</p> 41 41 </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p> 42 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 43 <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 44 <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 45 </div> ··· 112 112 {{ end }} 113 113 </div> 114 114 </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p> 115 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 116 </div> 117 117 <div class="flex gap-2 items-center"> 118 118 {{ if not .Verified }}
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 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 17 {{ block "addMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+2 -2
appview/pages/templates/spindles/fragments/spindleListing.html
··· 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 12 {{ .Instance }} 13 13 <span class="text-gray-500"> 14 - {{ .Created | shortTimeFmt }} ago 14 + {{ template "repo/fragments/shortTimeAgo" .Created }} 15 15 </span> 16 16 </a> 17 17 {{ else }} ··· 19 19 {{ i "hard-drive" "w-4 h-4" }} 20 20 {{ .Instance }} 21 21 <span class="text-gray-500"> 22 - {{ .Created | shortTimeFmt }} ago 22 + {{ template "repo/fragments/shortTimeAgo" .Created }} 23 23 </span> 24 24 </div> 25 25 {{ end }}
+14 -2
appview/pages/templates/spindles/index.html
··· 7 7 8 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 9 <div class="flex flex-col gap-6"> 10 - {{ block "all" . }} {{ end }} 10 + {{ block "about" . }} {{ end }} 11 + {{ block "list" . }} {{ end }} 11 12 {{ block "register" . }} {{ end }} 12 13 </div> 13 14 </section> 14 15 {{ end }} 15 16 16 - {{ define "all" }} 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" }} 17 29 <section class="rounded w-full flex flex-col gap-2"> 18 30 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 19 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 49 <p class="text-xl font-bold dark:text-white">Timeline</p> 50 50 </div> 51 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 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 }} 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 }} 126 67 </div> 127 - {{ end }} 68 + {{ end }} 69 + </div> 70 + {{ end }} 128 71 </div> 129 72 </div> 130 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 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> 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 }} 10 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 17 /> 18 18 <meta 19 19 property="og:description" 20 - content="login to tangled" 20 + content="login to or sign up for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 23 <link ··· 25 25 href="/static/tw.css?{{ cssContentHash }}" 26 26 type="text/css" 27 27 /> 28 - <title>login &middot; tangled</title> 28 + <title>login or sign up &middot; tangled</title> 29 29 </head> 30 30 <body class="flex items-center justify-center min-h-screen"> 31 31 <main class="max-w-md px-6 -mt-4"> ··· 51 51 name="handle" 52 52 tabindex="1" 53 53 required 54 + placeholder="foo.tngl.sh" 54 55 /> 55 56 <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. 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 60 </span> 61 61 </div> 62 62 ··· 69 69 <span>login</span> 70 70 </button> 71 71 </form> 72 - <p class="text-sm text-gray-500"> 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"> 73 120 Join our <a href="https://chat.tangled.sh">Discord</a> or 74 121 IRC channel: 75 122 <a href="https://web.libera.chat/#tangled"
+7 -49
appview/pages/templates/user/profile.html
··· 8 8 {{ end }} 9 9 10 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"> 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 13 <div class="grid grid-cols-1 gap-4"> 14 14 {{ template "user/fragments/profileCard" .Card }} 15 15 {{ block "punchcard" .Punchcard }} {{ end }} 16 16 </div> 17 17 </div> 18 - <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 18 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 19 <div class="grid grid-cols-1 gap-4"> 20 20 {{ block "ownRepos" . }}{{ end }} 21 21 {{ block "collaboratingRepos" . }}{{ end }} 22 22 </div> 23 23 </div> 24 - <div class="md:col-span-3 order-3 md:order-3"> 24 + <div class="md:col-span-4 order-3 md:order-3"> 25 25 {{ block "profileTimeline" . }}{{ end }} 26 26 </div> 27 27 </div> ··· 258 258 </button> 259 259 {{ end }} 260 260 </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4"> 261 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 262 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> 263 + {{ template "user/fragments/repoCard" (list $ . false) }} 285 264 {{ else }} 286 265 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 287 266 {{ end }} ··· 295 274 <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 296 275 <div id="collaborating" class="grid grid-cols-1 gap-4"> 297 276 {{ 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> 277 + {{ template "user/fragments/repoCard" (list $ . true) }} 320 278 {{ else }} 321 279 <p class="px-6 dark:text-white">This user is not collaborating.</p> 322 280 {{ end }}
+4 -25
appview/pages/templates/user/repos.html
··· 8 8 {{ end }} 9 9 10 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"> 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 13 {{ template "user/fragments/profileCard" .Card }} 14 14 </div> 15 - <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 15 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 16 {{ block "ownRepos" . }}{{ end }} 17 17 </div> 18 18 </div> ··· 22 22 <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 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> 25 + {{ template "user/fragments/repoCard" (list $ . false) }} 47 26 {{ else }} 48 27 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 49 28 {{ end }}
+1 -5
appview/pipelines/pipelines.go
··· 11 11 12 12 "tangled.sh/tangled.sh/core/appview/config" 13 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/idresolver" 15 14 "tangled.sh/tangled.sh/core/appview/oauth" 16 15 "tangled.sh/tangled.sh/core/appview/pages" 17 16 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 17 "tangled.sh/tangled.sh/core/eventconsumer" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/log" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 22 23 23 "github.com/go-chi/chi/v5" 24 24 "github.com/gorilla/websocket" 25 - "github.com/posthog/posthog-go" 26 25 ) 27 26 28 27 type Pipelines struct { ··· 34 33 spindlestream *eventconsumer.Consumer 35 34 db *db.DB 36 35 enforcer *rbac.Enforcer 37 - posthog posthog.Client 38 36 logger *slog.Logger 39 37 } 40 38 ··· 46 44 idResolver *idresolver.Resolver, 47 45 db *db.DB, 48 46 config *config.Config, 49 - posthog posthog.Client, 50 47 enforcer *rbac.Enforcer, 51 48 ) *Pipelines { 52 49 logger := log.New("pipelines") ··· 58 55 config: config, 59 56 spindlestream: spindlestream, 60 57 db: db, 61 - posthog: posthog, 62 58 enforcer: enforcer, 63 59 logger: logger, 64 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 14 "time" 15 15 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/config" 19 18 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 19 + "tangled.sh/tangled.sh/core/appview/notify" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/pages" 23 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/tid" 26 27 "tangled.sh/tangled.sh/core/types" 27 28 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 31 32 lexutil "github.com/bluesky-social/indigo/lex/util" 32 33 "github.com/go-chi/chi/v5" 33 34 "github.com/google/uuid" 34 - "github.com/posthog/posthog-go" 35 35 ) 36 36 37 37 type Pulls struct { ··· 41 41 idResolver *idresolver.Resolver 42 42 db *db.DB 43 43 config *config.Config 44 - posthog posthog.Client 44 + notifier notify.Notifier 45 45 } 46 46 47 47 func New( ··· 51 51 resolver *idresolver.Resolver, 52 52 db *db.DB, 53 53 config *config.Config, 54 - posthog posthog.Client, 54 + notifier notify.Notifier, 55 55 ) *Pulls { 56 56 return &Pulls{ 57 57 oauth: oauth, ··· 60 60 idResolver: resolver, 61 61 db: db, 62 62 config: config, 63 - posthog: posthog, 63 + notifier: notifier, 64 64 } 65 65 } 66 66 ··· 198 198 m[p.Sha] = p 199 199 } 200 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 + 201 212 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 202 213 LoggedInUser: user, 203 214 RepoInfo: repoInfo, ··· 208 219 MergeCheck: mergeCheckResponse, 209 220 ResubmitCheck: resubmitResult, 210 221 Pipelines: m, 222 + 223 + OrderedReactionKinds: db.OrderedReactionKinds, 224 + Reactions: reactionCountMap, 225 + UserReacted: userReactions, 211 226 }) 212 227 } 213 228 ··· 340 355 return 341 356 } 342 357 358 + var diffOpts types.DiffOpts 359 + if d := r.URL.Query().Get("diff"); d == "split" { 360 + diffOpts.Split = true 361 + } 362 + 343 363 pull, ok := r.Context().Value("pull").(*db.Pull) 344 364 if !ok { 345 365 log.Println("failed to get pull") ··· 380 400 Round: roundIdInt, 381 401 Submission: pull.Submissions[roundIdInt], 382 402 Diff: &diff, 403 + DiffOpts: diffOpts, 383 404 }) 384 405 385 406 } ··· 393 414 return 394 415 } 395 416 417 + var diffOpts types.DiffOpts 418 + if d := r.URL.Query().Get("diff"); d == "split" { 419 + diffOpts.Split = true 420 + } 421 + 396 422 pull, ok := r.Context().Value("pull").(*db.Pull) 397 423 if !ok { 398 424 log.Println("failed to get pull") ··· 448 474 Round: roundIdInt, 449 475 DidHandleMap: didHandleMap, 450 476 Interdiff: interdiff, 477 + DiffOpts: diffOpts, 451 478 }) 452 - return 453 479 } 454 480 455 481 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 529 555 530 556 // we want to group all stacked PRs into just one list 531 557 stacks := make(map[string]db.Stack) 558 + var shas []string 532 559 n := 0 533 560 for _, p := range pulls { 561 + // store the sha for later 562 + shas = append(shas, p.LatestSha()) 534 563 // this PR is stacked 535 564 if p.StackId != "" { 536 565 // we have already seen this PR stack ··· 549 578 } 550 579 pulls = pulls[:n] 551 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 + 552 598 identsToResolve := make([]string, len(pulls)) 553 599 for i, pull := range pulls { 554 600 identsToResolve[i] = pull.OwnerDid ··· 570 616 DidHandleMap: didHandleMap, 571 617 FilteringBy: state, 572 618 Stacks: stacks, 619 + Pipelines: m, 573 620 }) 574 - return 575 621 } 576 622 577 623 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 642 688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 689 Collection: tangled.RepoPullCommentNSID, 644 690 Repo: user.Did, 645 - Rkey: appview.TID(), 691 + Rkey: tid.TID(), 646 692 Record: &lexutil.LexiconTypeDecoder{ 647 693 Val: &tangled.RepoPullComment{ 648 694 Repo: &atUri, ··· 659 705 return 660 706 } 661 707 662 - // Create the pull comment in the database with the commentAt field 663 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 708 + comment := &db.PullComment{ 664 709 OwnerDid: user.Did, 665 710 RepoAt: f.RepoAt.String(), 666 711 PullId: pull.PullId, 667 712 Body: body, 668 713 CommentAt: atResp.Uri, 669 714 SubmissionId: pull.Submissions[roundNumber].ID, 670 - }) 715 + } 716 + 717 + // Create the pull comment in the database with the commentAt field 718 + commentId, err := db.NewPullComment(tx, comment) 671 719 if err != nil { 672 720 log.Println("failed to create pull comment", err) 673 721 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 681 729 return 682 730 } 683 731 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 - } 732 + s.notifier.NewPullComment(r.Context(), comment) 694 733 695 734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 696 735 return ··· 1019 1058 body = formatPatches[0].Body 1020 1059 } 1021 1060 1022 - rkey := appview.TID() 1061 + rkey := tid.TID() 1023 1062 initialSubmission := db.PullSubmission{ 1024 1063 Patch: patch, 1025 1064 SourceRev: sourceRev, 1026 1065 } 1027 - err = db.NewPull(tx, &db.Pull{ 1066 + pull := &db.Pull{ 1028 1067 Title: title, 1029 1068 Body: body, 1030 1069 TargetBranch: targetBranch, ··· 1035 1074 &initialSubmission, 1036 1075 }, 1037 1076 PullSource: pullSource, 1038 - }) 1077 + } 1078 + err = db.NewPull(tx, pull) 1039 1079 if err != nil { 1040 1080 log.Println("failed to create pull request", err) 1041 1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1075 1115 return 1076 1116 } 1077 1117 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 - } 1118 + s.notifier.NewPull(r.Context(), pull) 1088 1119 1089 1120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1090 1121 } ··· 1647 1678 } 1648 1679 1649 1680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1650 - return 1651 1681 } 1652 1682 1653 1683 func (s *Pulls) resubmitStackedPullHelper( ··· 1891 1921 } 1892 1922 1893 1923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1894 - return 1895 1924 } 1896 1925 1897 1926 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2015 2044 2016 2045 // auth filter: only owner or collaborators can close 2017 2046 roles := f.RolesInRepo(user) 2047 + isOwner := roles.IsOwner() 2018 2048 isCollaborator := roles.IsCollaborator() 2019 2049 isPullAuthor := user.Did == pull.OwnerDid 2020 - isCloseAllowed := isCollaborator || isPullAuthor 2050 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2021 2051 if !isCloseAllowed { 2022 2052 log.Println("failed to close pull") 2023 2053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2061 2091 } 2062 2092 2063 2093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2064 - return 2065 2094 } 2066 2095 2067 2096 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2083 2112 2084 2113 // auth filter: only owner or collaborators can close 2085 2114 roles := f.RolesInRepo(user) 2115 + isOwner := roles.IsOwner() 2086 2116 isCollaborator := roles.IsCollaborator() 2087 2117 isPullAuthor := user.Did == pull.OwnerDid 2088 - isCloseAllowed := isCollaborator || isPullAuthor 2118 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2089 2119 if !isCloseAllowed { 2090 2120 log.Println("failed to close pull") 2091 2121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2129 2159 } 2130 2160 2131 2161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2132 - return 2133 2162 } 2134 2163 2135 2164 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2155 2184 2156 2185 title := fp.Title 2157 2186 body := fp.Body 2158 - rkey := appview.TID() 2187 + rkey := tid.TID() 2159 2188 2160 2189 initialSubmission := db.PullSubmission{ 2161 2190 Patch: fp.Raw,
+2
appview/pulls/router.go
··· 44 44 r.Get("/", s.ResubmitPull) 45 45 r.Post("/", s.ResubmitPull) 46 46 }) 47 + // permissions here require us to know pull author 48 + // it is handled within the route 47 49 r.Post("/close", s.ClosePull) 48 50 r.Post("/reopen", s.ReopenPull) 49 51 // collaborators only
+2 -2
appview/repo/artifact.go
··· 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 "github.com/ipfs/go-cid" 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/db" 19 18 "tangled.sh/tangled.sh/core/appview/pages" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 20 "tangled.sh/tangled.sh/core/knotclient" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 "tangled.sh/tangled.sh/core/types" 23 23 ) 24 24 ··· 64 64 65 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 66 67 - rkey := appview.TID() 67 + rkey := tid.TID() 68 68 createdAt := time.Now() 69 69 70 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+49 -25
appview/repo/index.go
··· 58 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 59 } 60 60 61 + sortFiles(result.Files) 62 + 61 63 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 62 64 if a.Name == result.Ref { 63 65 return -1 ··· 123 125 } 124 126 } 125 127 126 - languageInfo, err := getLanguageInfo(f, signedClient, ref) 128 + // TODO: a bit dirty 129 + languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 127 130 if err != nil { 128 131 log.Printf("failed to compute language percentages: %s", err) 129 132 // non-fatal ··· 153 156 Languages: languageInfo, 154 157 Pipelines: pipelines, 155 158 }) 156 - return 157 159 } 158 160 159 - func getLanguageInfo( 161 + func (rp *Repo) getLanguageInfo( 160 162 f *reporesolver.ResolvedRepo, 161 163 signedClient *knotclient.SignedClient, 162 - ref string, 164 + isDefaultRef bool, 163 165 ) ([]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 - } 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 + ) 176 172 177 - var languageStats []types.RepoLanguageDetails 178 - var otherPercentage float32 = 0 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 + } 179 182 180 - for lang, size := range repoLanguages.Languages { 181 - percentage := (float32(size) / float32(totalSize)) * 100 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 + } 182 192 183 - if percentage <= 0.5 { 184 - otherPercentage += percentage 185 - continue 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) 186 198 } 199 + } 187 200 188 - color := enry.GetColor(lang) 201 + var total int64 202 + for _, l := range langs { 203 + total += l.Bytes 204 + } 189 205 190 - languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color}) 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 + }) 191 215 } 192 216 193 217 sort.Slice(languageStats, func(i, j int) bool {
+441 -128
appview/repo/repo.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log" 11 + "log/slog" 11 12 "net/http" 12 13 "net/url" 13 - "path" 14 + "path/filepath" 14 15 "slices" 15 - "sort" 16 16 "strconv" 17 17 "strings" 18 18 "time" 19 19 20 20 "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 21 "tangled.sh/tangled.sh/core/appview/commitverify" 23 22 "tangled.sh/tangled.sh/core/appview/config" 24 23 "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/idresolver" 24 + "tangled.sh/tangled.sh/core/appview/notify" 26 25 "tangled.sh/tangled.sh/core/appview/oauth" 27 26 "tangled.sh/tangled.sh/core/appview/pages" 28 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 + "tangled.sh/tangled.sh/core/idresolver" 31 31 "tangled.sh/tangled.sh/core/knotclient" 32 32 "tangled.sh/tangled.sh/core/patchutil" 33 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 34 35 "tangled.sh/tangled.sh/core/types" 35 36 36 37 securejoin "github.com/cyphar/filepath-securejoin" 37 38 "github.com/go-chi/chi/v5" 38 39 "github.com/go-git/go-git/v5/plumbing" 39 - "github.com/posthog/posthog-go" 40 40 41 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 42 43 lexutil "github.com/bluesky-social/indigo/lex/util" 43 44 ) 44 45 ··· 51 52 spindlestream *eventconsumer.Consumer 52 53 db *db.DB 53 54 enforcer *rbac.Enforcer 54 - posthog posthog.Client 55 + notifier notify.Notifier 56 + logger *slog.Logger 55 57 } 56 58 57 59 func New( ··· 62 64 idResolver *idresolver.Resolver, 63 65 db *db.DB, 64 66 config *config.Config, 65 - posthog posthog.Client, 67 + notifier notify.Notifier, 66 68 enforcer *rbac.Enforcer, 69 + logger *slog.Logger, 67 70 ) *Repo { 68 71 return &Repo{oauth: oauth, 69 72 repoResolver: repoResolver, ··· 72 75 config: config, 73 76 spindlestream: spindlestream, 74 77 db: db, 75 - posthog: posthog, 78 + notifier: notifier, 76 79 enforcer: enforcer, 80 + logger: logger, 77 81 } 78 82 } 79 83 ··· 106 110 return 107 111 } 108 112 109 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 113 + tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 110 114 if err != nil { 111 115 log.Println("failed to reach knotserver", err) 112 116 return 113 117 } 114 118 115 119 tagMap := make(map[string][]string) 116 - for _, tag := range result.Tags { 120 + for _, tag := range tagResult.Tags { 117 121 hash := tag.Hash 118 122 if tag.Tag != nil { 119 123 hash = tag.Tag.Target.String() ··· 121 125 tagMap[hash] = append(tagMap[hash], tag.Name) 122 126 } 123 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 + 124 139 user := rp.oauth.GetUser(r) 125 140 126 141 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) ··· 154 169 VerifiedCommits: vc, 155 170 Pipelines: pipelines, 156 171 }) 157 - return 158 172 } 159 173 160 174 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { ··· 169 183 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 170 184 RepoInfo: f.RepoInfo(user), 171 185 }) 172 - return 173 186 } 174 187 175 188 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 268 281 protocol = "https" 269 282 } 270 283 284 + var diffOpts types.DiffOpts 285 + if d := r.URL.Query().Get("diff"); d == "split" { 286 + diffOpts.Split = true 287 + } 288 + 271 289 if !plumbing.IsHash(ref) { 272 290 rp.pages.Error404(w) 273 291 return ··· 321 339 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 322 340 VerifiedCommit: vc, 323 341 Pipeline: pipeline, 342 + DiffOpts: diffOpts, 324 343 }) 325 - return 326 344 } 327 345 328 346 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 359 377 360 378 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 361 379 // so we can safely redirect to the "parent" (which is the same file). 362 - if len(result.Files) == 0 && result.Parent == treePath { 380 + unescapedTreePath, _ := url.PathUnescape(treePath) 381 + if len(result.Files) == 0 && result.Parent == unescapedTreePath { 363 382 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 364 383 return 365 384 } ··· 374 393 } 375 394 } 376 395 377 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 378 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 396 + sortFiles(result.Files) 379 397 380 398 rp.pages.RepoTree(w, pages.RepoTreeParams{ 381 399 LoggedInUser: user, 382 400 BreadCrumbs: breadcrumbs, 383 - BaseTreeLink: baseTreeLink, 384 - BaseBlobLink: baseBlobLink, 401 + TreePath: treePath, 385 402 RepoInfo: f.RepoInfo(user), 386 403 RepoTreeResponse: result, 387 404 }) 388 - return 389 405 } 390 406 391 407 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 443 459 ArtifactMap: artifactMap, 444 460 DanglingArtifacts: danglingArtifacts, 445 461 }) 446 - return 447 462 } 448 463 449 464 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 465 480 return 466 481 } 467 482 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 - }) 483 + sortBranches(result.Branches) 484 484 485 485 user := rp.oauth.GetUser(r) 486 486 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 488 488 RepoInfo: f.RepoInfo(user), 489 489 RepoBranchesResponse: *result, 490 490 }) 491 - return 492 491 } 493 492 494 493 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 539 538 showRendered = r.URL.Query().Get("code") != "true" 540 539 } 541 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 + 542 566 user := rp.oauth.GetUser(r) 543 567 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 544 568 LoggedInUser: user, ··· 547 571 BreadCrumbs: breadcrumbs, 548 572 ShowRendered: showRendered, 549 573 RenderToggle: renderToggle, 574 + Unsupported: unsupported, 575 + IsImage: isImage, 576 + IsVideo: isVideo, 577 + ContentSrc: contentSrc, 550 578 }) 551 - return 552 579 } 553 580 554 581 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 555 582 f, err := rp.repoResolver.Resolve(r) 556 583 if err != nil { 557 584 log.Println("failed to get repo and knot", err) 585 + w.WriteHeader(http.StatusBadRequest) 558 586 return 559 587 } 560 588 ··· 565 593 if !rp.config.Core.Dev { 566 594 protocol = "https" 567 595 } 568 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 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) 569 598 if err != nil { 570 - log.Println("failed to reach knotserver", err) 599 + log.Println("failed to reach knotserver:", err) 600 + rp.pages.Error503(w) 571 601 return 572 602 } 603 + defer resp.Body.Close() 573 604 574 - body, err := io.ReadAll(resp.Body) 575 - if err != nil { 576 - log.Printf("Error reading response body: %v", err) 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) 577 609 return 578 610 } 579 611 580 - var result types.RepoBlobResponse 581 - err = json.Unmarshal(body, &result) 612 + contentType := resp.Header.Get("Content-Type") 613 + body, err := io.ReadAll(resp.Body) 582 614 if err != nil { 583 - log.Println("failed to parse response:", err) 615 + log.Printf("error reading response body from knotserver: %v", err) 616 + w.WriteHeader(http.StatusInternalServerError) 584 617 return 585 618 } 586 619 587 - if result.IsBinary { 588 - w.Header().Set("Content-Type", "application/octet-stream") 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) 589 625 w.Write(body) 626 + } else { 627 + w.WriteHeader(http.StatusUnsupportedMediaType) 628 + w.Write([]byte("unsupported content type")) 590 629 return 591 630 } 592 - 593 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 594 - w.Write([]byte(result.Contents)) 595 - return 596 631 } 597 632 598 633 // modify the spindle configured for this repo 599 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 + 600 646 f, err := rp.repoResolver.Resolve(r) 601 647 if err != nil { 602 - log.Println("failed to get repo and knot", err) 603 - w.WriteHeader(http.StatusBadRequest) 648 + fail("Failed to resolve repo. Try again later", err) 604 649 return 605 650 } 606 651 607 652 repoAt := f.RepoAt 608 653 rkey := repoAt.RecordKey().String() 609 654 if rkey == "" { 610 - log.Println("invalid aturi for repo", err) 611 - w.WriteHeader(http.StatusInternalServerError) 655 + fail("Failed to resolve repo. Try again later", err) 612 656 return 613 657 } 614 658 615 - user := rp.oauth.GetUser(r) 616 - 617 659 newSpindle := r.FormValue("spindle") 618 660 client, err := rp.oauth.AuthorizedClient(r) 619 661 if err != nil { 620 - log.Println("failed to get client") 621 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 662 + fail("Failed to authorize. Try again later.", err) 622 663 return 623 664 } 624 665 625 666 // ensure that this is a valid spindle for this user 626 667 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 627 668 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.") 669 + fail("Failed to find spindles. Try again later.", err) 630 670 return 631 671 } 632 672 633 673 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.") 674 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 636 675 return 637 676 } 638 677 639 678 // optimistic update 640 679 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 641 680 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.") 681 + fail("Failed to update spindle. Try again later.", err) 644 682 return 645 683 } 646 684 647 685 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 648 686 if err != nil { 649 - // failed to get record 650 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 687 + fail("Failed to update spindle, no record found on PDS.", err) 651 688 return 652 689 } 653 690 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 668 705 }) 669 706 670 707 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.") 708 + fail("Failed to update spindle, unable to save to PDS.", err) 674 709 return 675 710 } 676 711 ··· 680 715 eventconsumer.NewSpindleSource(newSpindle), 681 716 ) 682 717 683 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 718 + rp.pages.HxRefresh(w) 684 719 } 685 720 686 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 + 687 727 f, err := rp.repoResolver.Resolve(r) 688 728 if err != nil { 689 - log.Println("failed to get repo and knot", err) 729 + l.Error("failed to get repo and knot", "err", err) 690 730 return 691 731 } 692 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 + 693 739 collaborator := r.FormValue("collaborator") 694 740 if collaborator == "" { 695 - http.Error(w, "malformed form", http.StatusBadRequest) 741 + fail("Invalid form.", nil) 696 742 return 697 743 } 744 + 745 + // remove a single leading `@`, to make @handle work with ResolveIdent 746 + collaborator = strings.TrimPrefix(collaborator, "@") 698 747 699 748 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 700 749 if err != nil { 701 - w.Write([]byte("failed to resolve collaborator did to a handle")) 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) 702 756 return 703 757 } 704 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 758 + l = l.With("collaborator", collaboratorIdent.Handle) 759 + l = l.With("knot", f.Knot) 705 760 706 - // TODO: create an atproto record for this 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 + } 707 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") 708 792 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 709 793 if err != nil { 710 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 794 + fail("Failed to add to knot.", err) 711 795 return 712 796 } 713 797 714 798 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 715 799 if err != nil { 716 - log.Println("failed to create client to ", f.Knot) 800 + fail("Failed to add to knot.", err) 717 801 return 718 802 } 719 803 720 804 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 721 805 if err != nil { 722 - log.Printf("failed to make request to %s: %s", f.Knot, err) 806 + fail("Knot was unreachable.", err) 723 807 return 724 808 } 725 809 726 810 if ksResp.StatusCode != http.StatusNoContent { 727 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 811 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 728 812 return 729 813 } 730 814 731 815 tx, err := rp.db.BeginTx(r.Context(), nil) 732 816 if err != nil { 733 - log.Println("failed to start tx") 734 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 817 + fail("Failed to add collaborator.", err) 735 818 return 736 819 } 737 820 defer func() { 738 821 tx.Rollback() 739 822 err = rp.enforcer.E.LoadPolicy() 740 823 if err != nil { 741 - log.Println("failed to rollback policies") 824 + fail("Failed to add collaborator.", err) 742 825 } 743 826 }() 744 827 745 828 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 746 829 if err != nil { 747 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 830 + fail("Failed to add collaborator permissions.", err) 748 831 return 749 832 } 750 833 751 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 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 + }) 752 841 if err != nil { 753 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 842 + fail("Failed to add collaborator.", err) 754 843 return 755 844 } 756 845 757 846 err = tx.Commit() 758 847 if err != nil { 759 - log.Println("failed to commit changes", err) 760 - http.Error(w, err.Error(), http.StatusInternalServerError) 848 + fail("Failed to add collaborator.", err) 761 849 return 762 850 } 763 851 764 852 err = rp.enforcer.E.SavePolicy() 765 853 if err != nil { 766 - log.Println("failed to update ACLs", err) 767 - http.Error(w, err.Error(), http.StatusInternalServerError) 854 + fail("Failed to update collaborator permissions.", err) 768 855 return 769 856 } 770 857 771 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 772 - 858 + rp.pages.HxRefresh(w) 773 859 } 774 860 775 861 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 921 1007 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 922 1008 } 923 1009 924 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 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 + 925 1016 f, err := rp.repoResolver.Resolve(r) 926 1017 if err != nil { 927 1018 log.Println("failed to get repo and knot", err) 928 1019 return 929 1020 } 930 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 + 931 1049 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 - } 1050 + case http.MethodPut: 1051 + errorId := "add-secret-error" 939 1052 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 - } 1053 + value := r.FormValue("value") 1054 + if value == "" { 1055 + w.WriteHeader(http.StatusBadRequest) 1056 + return 946 1057 } 947 1058 948 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 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 + ) 949 1068 if err != nil { 950 - log.Println("failed to create unsigned client", err) 1069 + l.Error("Failed to add secret.", "err", err) 1070 + rp.pages.Notice(w, errorId, "Failed to add secret.") 951 1071 return 952 1072 } 953 1073 954 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 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 + ) 955 1085 if err != nil { 956 - log.Println("failed to reach knotserver", err) 1086 + l.Error("Failed to delete secret.", "err", err) 1087 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 957 1088 return 958 1089 } 1090 + } 959 1091 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 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 965 1251 } 1252 + } 966 1253 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, 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(), 975 1273 }) 976 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 + }) 977 1285 } 978 1286 979 1287 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1093 1401 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1094 1402 sourceAt := f.RepoAt.String() 1095 1403 1096 - rkey := appview.TID() 1404 + rkey := tid.TID() 1097 1405 repo := &db.Repo{ 1098 1406 Did: user.Did, 1099 1407 Name: forkName, ··· 1218 1526 return 1219 1527 } 1220 1528 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 - }) 1529 + 1530 + sortBranches(branches) 1224 1531 1225 1532 var defaultBranch string 1226 1533 for _, b := range branches { ··· 1267 1574 if err != nil { 1268 1575 log.Println("failed to get repo and knot", err) 1269 1576 return 1577 + } 1578 + 1579 + var diffOpts types.DiffOpts 1580 + if d := r.URL.Query().Get("diff"); d == "split" { 1581 + diffOpts.Split = true 1270 1582 } 1271 1583 1272 1584 // if user is navigating to one of ··· 1331 1643 Base: base, 1332 1644 Head: head, 1333 1645 Diff: &diff, 1646 + DiffOpts: diffOpts, 1334 1647 }) 1335 1648 1336 1649 }
+34
appview/repo/repo_util.go
··· 5 5 "crypto/rand" 6 6 "fmt" 7 7 "math/big" 8 + "slices" 9 + "sort" 10 + "strings" 8 11 9 12 "tangled.sh/tangled.sh/core/appview/db" 10 13 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 + "tangled.sh/tangled.sh/core/types" 11 15 12 16 "github.com/go-git/go-git/v5/plumbing/object" 13 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 + } 14 48 15 49 func uniqueEmails(commits []*object.Commit) []string { 16 50 emails := make(map[string]struct{})
+2
appview/repo/router.go
··· 74 74 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 75 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 76 r.Put("/branches/default", rp.SetDefaultBranch) 77 + r.Put("/secrets", rp.Secrets) 78 + r.Delete("/secrets", rp.Secrets) 77 79 }) 78 80 }) 79 81
+5 -4
appview/reporesolver/resolver.go
··· 17 17 "github.com/go-chi/chi/v5" 18 18 "tangled.sh/tangled.sh/core/appview/config" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/pages" 23 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/rbac" 26 26 ) ··· 149 149 for _, item := range repoCollaborators { 150 150 // currently only two roles: owner and member 151 151 var role string 152 - if item[3] == "repo:owner" { 152 + switch item[3] { 153 + case "repo:owner": 153 154 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 155 + case "repo:collaborator": 155 156 role = "collaborator" 156 - } else { 157 + default: 157 158 continue 158 159 } 159 160
+2 -2
appview/settings/settings.go
··· 12 12 13 13 "github.com/go-chi/chi/v5" 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview" 16 15 "tangled.sh/tangled.sh/core/appview/config" 17 16 "tangled.sh/tangled.sh/core/appview/db" 18 17 "tangled.sh/tangled.sh/core/appview/email" 19 18 "tangled.sh/tangled.sh/core/appview/middleware" 20 19 "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 366 return 367 367 } 368 368 369 - rkey := appview.TID() 369 + rkey := tid.TID() 370 370 371 371 tx, err := s.Db.Begin() 372 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 10 11 11 "github.com/go-chi/chi/v5" 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 13 "tangled.sh/tangled.sh/core/appview/config" 15 14 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 15 "tangled.sh/tangled.sh/core/appview/middleware" 18 16 "tangled.sh/tangled.sh/core/appview/oauth" 19 17 "tangled.sh/tangled.sh/core/appview/pages" 20 18 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 + "tangled.sh/tangled.sh/core/idresolver" 21 20 "tangled.sh/tangled.sh/core/rbac" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 104 104 105 105 repos, err := db.GetRepos( 106 106 s.Db, 107 + 0, 107 108 db.FilterEq("spindle", instance), 108 109 ) 109 110 if err != nil { ··· 113 114 } 114 115 115 116 identsToResolve := make([]string, len(members)) 116 - for i, member := range members { 117 - identsToResolve[i] = member 118 - } 117 + copy(identsToResolve, members) 119 118 resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 120 119 didHandleMap := make(map[string]string) 121 120 for _, identity := range resolvedIds { ··· 257 256 258 257 // ok 259 258 s.Pages.HxRefresh(w) 260 - return 261 259 } 262 260 263 261 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 305 303 s.Enforcer.E.LoadPolicy() 306 304 }() 307 305 308 - err = db.DeleteSpindle( 306 + // remove spindle members first 307 + err = db.RemoveSpindleMember( 309 308 tx, 310 - db.FilterEq("owner", user.Did), 309 + db.FilterEq("did", user.Did), 311 310 db.FilterEq("instance", instance), 312 311 ) 313 312 if err != nil { 314 - l.Error("failed to delete spindle", "err", err) 313 + l.Error("failed to remove spindle members", "err", err) 315 314 fail() 316 315 return 317 316 } 318 317 319 - err = s.Enforcer.RemoveSpindle(instance) 318 + err = db.DeleteSpindle( 319 + tx, 320 + db.FilterEq("owner", user.Did), 321 + db.FilterEq("instance", instance), 322 + ) 320 323 if err != nil { 321 - l.Error("failed to update ACL", "err", err) 324 + l.Error("failed to delete spindle", "err", err) 322 325 fail() 323 326 return 324 327 } 325 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 + 326 339 client, err := s.OAuth.AuthorizedClient(r) 327 340 if err != nil { 328 341 l.Error("failed to authorize client", "err", err) ··· 520 533 s.Enforcer.E.LoadPolicy() 521 534 }() 522 535 523 - rkey := appview.TID() 536 + rkey := tid.TID() 524 537 525 538 // add member to db 526 539 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 579 592 l := s.Logger.With("handler", "removeMember") 580 593 581 594 noticeId := "operation-error" 582 - defaultErr := "Failed to add member. Try again later." 595 + defaultErr := "Failed to remove member. Try again later." 583 596 fail := func() { 584 597 s.Pages.Notice(w, noticeId, defaultErr) 585 598 } ··· 707 720 708 721 // ok 709 722 s.Pages.HxRefresh(w) 710 - return 711 723 }
+13 -26
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/posthog/posthog-go" 11 10 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 12 "tangled.sh/tangled.sh/core/appview/pages" 13 + "tangled.sh/tangled.sh/core/tid" 15 14 ) 16 15 17 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 41 switch r.Method { 43 42 case http.MethodPost: 44 43 createdAt := time.Now().Format(time.RFC3339) 45 - rkey := appview.TID() 44 + rkey := tid.TID() 46 45 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 46 Collection: tangled.GraphFollowNSID, 48 47 Repo: currentUser.Did, ··· 58 57 return 59 58 } 60 59 61 - err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey) 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) 62 69 if err != nil { 63 70 log.Println("failed to follow", err) 64 71 return 65 72 } 66 73 67 - log.Println("created atproto record: ", resp.Uri) 74 + s.notifier.NewFollow(r.Context(), follow) 68 75 69 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 70 77 UserDid: subjectIdent.DID.String(), 71 78 FollowStatus: db.IsFollowing, 72 79 }) 73 80 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 81 return 86 82 case http.MethodDelete: 87 83 // find the record in the db ··· 113 109 FollowStatus: db.IsNotFollowing, 114 110 }) 115 111 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 - } 112 + s.notifier.DeleteFollow(r.Context(), follow) 126 113 127 114 return 128 115 }
+75 -12
appview/state/knotstream.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "slices" 8 9 "time" ··· 18 19 "tangled.sh/tangled.sh/core/workflow" 19 20 20 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 + "github.com/go-git/go-git/v5/plumbing" 21 23 "github.com/posthog/posthog-go" 22 24 ) 23 25 ··· 39 41 40 42 cfg := ec.ConsumerConfig{ 41 43 Sources: srcs, 42 - ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev), 44 + ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev), 43 45 RetryInterval: c.Knotstream.RetryInterval, 44 46 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 45 47 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 53 55 return ec.NewConsumer(cfg), nil 54 56 } 55 57 56 - func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 58 + func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 57 59 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 58 60 switch msg.Nsid { 59 61 case tangled.GitRefUpdateNSID: ··· 81 83 return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key()) 82 84 } 83 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 { 84 101 knownEmails, err := db.GetAllEmails(d, record.CommitterDid) 85 102 if err != nil { 86 103 return err 87 104 } 105 + 88 106 count := 0 89 107 for _, ke := range knownEmails { 90 108 if record.Meta == nil { ··· 108 126 Date: time.Now(), 109 127 Count: count, 110 128 } 111 - if err := db.AddPunch(d, punch); err != nil { 112 - return err 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) 113 154 } 114 155 115 - if !dev { 116 - err = pc.Enqueue(posthog.Capture{ 117 - DistinctId: record.CommitterDid, 118 - Event: "git_ref_update", 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, 119 168 }) 120 - if err != nil { 121 - // non-fatal, TODO: log this 122 - } 123 169 } 124 170 125 - return nil 171 + return db.InsertRepoLanguages(d, langs) 126 172 } 127 173 128 174 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 138 184 139 185 if record.TriggerMetadata.Repo == nil { 140 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) 141 204 } 142 205 143 206 // trigger info
+12 -15
appview/state/profile.go
··· 16 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 18 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 19 "tangled.sh/tangled.sh/core/api/tangled" 21 20 "tangled.sh/tangled.sh/core/appview/db" 22 21 "tangled.sh/tangled.sh/core/appview/pages" ··· 50 49 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 51 50 } 52 51 53 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 52 + repos, err := db.GetRepos( 53 + s.db, 54 + 0, 55 + db.FilterEq("did", ident.DID.String()), 56 + ) 54 57 if err != nil { 55 58 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 56 59 } ··· 171 174 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 172 175 } 173 176 174 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 177 + repos, err := db.GetRepos( 178 + s.db, 179 + 0, 180 + db.FilterEq("did", ident.DID.String()), 181 + ) 175 182 if err != nil { 176 183 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 177 184 } ··· 192 199 s.pages.ReposPage(w, pages.ReposPageParams{ 193 200 LoggedInUser: loggedInUser, 194 201 Repos: repos, 202 + DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 195 203 Card: pages.ProfileCard{ 196 204 UserDid: ident.DID.String(), 197 205 UserHandle: ident.Handle.String(), ··· 257 265 } 258 266 259 267 s.updateProfile(profile, w, r) 260 - return 261 268 } 262 269 263 270 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 297 304 profile.PinnedRepos = pinnedRepos 298 305 299 306 s.updateProfile(profile, w, r) 300 - return 301 307 } 302 308 303 309 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 362 368 return 363 369 } 364 370 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 - } 371 + s.notifier.UpdateProfile(r.Context(), profile) 374 372 375 373 s.pages.HxRedirect(w, "/"+user.Did) 376 - return 377 374 } 378 375 379 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 7 "github.com/go-chi/chi/v5" 8 8 "github.com/gorilla/sessions" 9 9 "tangled.sh/tangled.sh/core/appview/issues" 10 + "tangled.sh/tangled.sh/core/appview/knots" 10 11 "tangled.sh/tangled.sh/core/appview/middleware" 11 12 oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 12 13 "tangled.sh/tangled.sh/core/appview/pipelines" 13 14 "tangled.sh/tangled.sh/core/appview/pulls" 14 15 "tangled.sh/tangled.sh/core/appview/repo" 15 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 16 18 "tangled.sh/tangled.sh/core/appview/spindles" 17 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 18 20 "tangled.sh/tangled.sh/core/log" ··· 101 103 102 104 r.Get("/", s.Timeline) 103 105 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 106 r.Route("/repo", func(r chi.Router) { 122 107 r.Route("/new", func(r chi.Router) { 123 108 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 135 120 r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { 136 121 r.Post("/", s.Star) 137 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) 138 128 }) 139 129 140 130 r.Route("/profile", func(r chi.Router) { ··· 146 136 }) 147 137 148 138 r.Mount("/settings", s.SettingsRouter()) 139 + r.Mount("/knots", s.KnotsRouter(mw)) 149 140 r.Mount("/spindles", s.SpindlesRouter()) 141 + r.Mount("/signup", s.SignupRouter()) 150 142 r.Mount("/", s.OAuthRouter()) 151 143 152 144 r.Get("/keys/{user}", s.Keys) 145 + r.Get("/terms", s.TermsOfService) 146 + r.Get("/privacy", s.PrivacyPolicy) 153 147 154 148 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 155 149 s.pages.Error404(w) ··· 190 184 return spindles.Router() 191 185 } 192 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 + 193 204 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) 205 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 195 206 return issues.Router(mw) 196 - 197 207 } 198 208 199 209 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) 210 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 201 211 return pulls.Router(mw) 202 212 } 203 213 204 214 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) 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) 206 217 return repo.Router(mw) 207 218 } 208 219 209 220 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) 221 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 211 222 return pipes.Router(mw) 212 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 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "github.com/posthog/posthog-go" 12 11 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 12 "tangled.sh/tangled.sh/core/appview/db" 15 13 "tangled.sh/tangled.sh/core/appview/pages" 14 + "tangled.sh/tangled.sh/core/tid" 16 15 ) 17 16 18 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 38 switch r.Method { 40 39 case http.MethodPost: 41 40 createdAt := time.Now().Format(time.RFC3339) 42 - rkey := appview.TID() 41 + rkey := tid.TID() 43 42 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 43 Collection: tangled.FeedStarNSID, 45 44 Repo: currentUser.Did, ··· 54 53 log.Println("failed to create atproto record", err) 55 54 return 56 55 } 56 + log.Println("created atproto record: ", resp.Uri) 57 57 58 - err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey) 58 + star := &db.Star{ 59 + StarredByDid: currentUser.Did, 60 + RepoAt: subjectUri, 61 + Rkey: rkey, 62 + } 63 + 64 + err = db.AddStar(s.db, star) 59 65 if err != nil { 60 66 log.Println("failed to star", err) 61 67 return ··· 66 72 log.Println("failed to get star count for ", subjectUri) 67 73 } 68 74 69 - log.Println("created atproto record: ", resp.Uri) 75 + s.notifier.NewStar(r.Context(), star) 70 76 71 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 77 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 72 78 IsStarred: true, 73 79 RepoAt: subjectUri, 74 80 Stats: db.RepoStats{ ··· 76 82 }, 77 83 }) 78 84 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 85 return 91 86 case http.MethodDelete: 92 87 // find the record in the db ··· 119 114 return 120 115 } 121 116 122 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 117 + s.notifier.DeleteStar(r.Context(), star) 118 + 119 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 123 120 IsStarred: false, 124 121 RepoAt: subjectUri, 125 122 Stats: db.RepoStats{ 126 123 StarCount: starCount, 127 124 }, 128 125 }) 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 126 141 127 return 142 128 }
+27 -353
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 5 "fmt" 9 6 "log" 10 7 "log/slog" ··· 13 10 "time" 14 11 15 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 - "github.com/bluesky-social/indigo/atproto/syntax" 17 13 lexutil "github.com/bluesky-social/indigo/lex/util" 18 14 securejoin "github.com/cyphar/filepath-securejoin" 19 15 "github.com/go-chi/chi/v5" ··· 24 20 "tangled.sh/tangled.sh/core/appview/cache/session" 25 21 "tangled.sh/tangled.sh/core/appview/config" 26 22 "tangled.sh/tangled.sh/core/appview/db" 27 - "tangled.sh/tangled.sh/core/appview/idresolver" 23 + "tangled.sh/tangled.sh/core/appview/notify" 28 24 "tangled.sh/tangled.sh/core/appview/oauth" 29 25 "tangled.sh/tangled.sh/core/appview/pages" 26 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 + "tangled.sh/tangled.sh/core/idresolver" 32 30 "tangled.sh/tangled.sh/core/jetstream" 33 31 "tangled.sh/tangled.sh/core/knotclient" 34 32 tlog "tangled.sh/tangled.sh/core/log" 35 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 36 35 ) 37 36 38 37 type State struct { 39 38 db *db.DB 39 + notifier notify.Notifier 40 40 oauth *oauth.OAuth 41 41 enforcer *rbac.Enforcer 42 - tidClock syntax.TIDClock 43 42 pages *pages.Pages 44 43 sess *session.SessionStore 45 44 idResolver *idresolver.Resolver ··· 62 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 63 62 } 64 63 65 - clock := syntax.NewTIDClock(0) 66 - 67 64 pgs := pages.NewPages(config) 68 65 69 - res, err := idresolver.RedisResolver(config.Redis) 66 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 70 67 if err != nil { 71 68 log.Printf("failed to create redis resolver: %v", err) 72 69 res = idresolver.DefaultResolver() ··· 134 131 } 135 132 spindlestream.Start(ctx) 136 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 + 137 140 state := &State{ 138 141 d, 142 + notifier, 139 143 oauth, 140 144 enforcer, 141 - clock, 142 145 pgs, 143 146 sess, 144 147 res, ··· 153 156 return state, nil 154 157 } 155 158 156 - func TID(c *syntax.TIDClock) string { 157 - return c.Next().String() 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 + }) 158 171 } 159 172 160 173 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 201 214 return 202 215 } 203 216 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 217 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 241 218 user := chi.URLParam(r, "user") 242 219 user = strings.TrimPrefix(user, "@") ··· 269 246 } 270 247 } 271 248 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 249 func validateRepoName(name string) error { 567 250 // check for path traversal attempts 568 251 if name == "." || name == ".." || ··· 661 344 return 662 345 } 663 346 664 - rkey := appview.TID() 347 + rkey := tid.TID() 665 348 repo := &db.Repo{ 666 349 Did: user.Did, 667 350 Name: repoName, ··· 757 440 return 758 441 } 759 442 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 - } 443 + s.notifier.NewRepo(r.Context(), repo) 770 444 771 445 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 772 446 return
+14 -6
appview/state/userutil/userutil.go
··· 5 5 "strings" 6 6 ) 7 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 + 8 13 func IsHandleNoAt(s string) bool { 9 14 // 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) 15 + return handleRegex.MatchString(s) 12 16 } 13 17 14 18 func UnflattenDid(s string) string { ··· 29 33 // Reconstruct as a standard DID format using Replace 30 34 // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" 31 35 reconstructed := strings.Replace(s, "-", ":", 2) 32 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 33 36 34 - return re.MatchString(reconstructed) 37 + return didRegex.MatchString(reconstructed) 35 38 } 36 39 37 40 // FlattenDid converts a DID to a flattened format. ··· 46 49 47 50 // IsDid checks if the given string is a standard DID. 48 51 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) 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) 51 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 87 88 88 return &out, nil 89 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 1 export default { 2 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 + 3 17 const url = new URL(request.url); 4 18 const { pathname, searchParams } = url; 5 19 ··· 60 74 const profile = await profileResponse.json(); 61 75 const avatar = profile.avatar; 62 76 63 - if (!avatar) { 64 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 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; 65 94 } 66 95 67 96 // Resize if requested 68 97 let avatarResponse; 69 98 if (resizeToTiny) { 70 - avatarResponse = await fetch(avatar, { 99 + avatarResponse = await fetch(avatarUrl, { 71 100 cf: { 72 101 image: { 73 102 width: 32, ··· 78 107 }, 79 108 }); 80 109 } else { 81 - avatarResponse = await fetch(avatar); 110 + avatarResponse = await fetch(avatarUrl); 82 111 } 83 112 84 113 if (!avatarResponse.ok) {
+4
cmd/gen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 18 19 tangled.FeedStar{}, 19 20 tangled.GitRefUpdate{}, 20 21 tangled.GitRefUpdate_Meta{}, 21 22 tangled.GitRefUpdate_Meta_CommitCount{}, 22 23 tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 24 + tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 + tangled.GitRefUpdate_Pair{}, 23 26 tangled.GraphFollow{}, 24 27 tangled.KnotMember{}, 25 28 tangled.Pipeline{}, ··· 37 40 tangled.PublicKey{}, 38 41 tangled.Repo{}, 39 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 40 44 tangled.RepoIssue{}, 41 45 tangled.RepoIssueComment{}, 42 46 tangled.RepoIssueState{},
+51 -7
docs/hacking.md
··· 32 32 nix run .#watch-tailwind 33 33 ``` 34 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 + 35 51 ## running a knot 36 52 37 53 An end-to-end knot setup requires setting up a machine with ··· 39 55 quite cumbersome. So the nix flake provides a 40 56 `nixosConfiguration` to do so. 41 57 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. 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. 45 62 46 63 You can now start a lightweight NixOS VM using 47 64 `nixos-shell` like so: 48 65 49 66 ```bash 50 - QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM 67 + nix run .#vm 68 + # or nixos-shell --flake .#vm 51 69 52 70 # hit Ctrl-a + c + q to exit the VM 53 71 ``` 54 72 55 - This starts a knot on port 6000 with `ssh` exposed on port 56 - 2222. You can push repositories to this VM with this ssh 57 - config block on your main machine: 73 + This starts a knot on port 6000, a spindle on port 6555 74 + with `ssh` exposed on port 2222. You can push repositories 75 + to this VM with this ssh config block on your main machine: 58 76 59 77 ```bash 60 78 Host nixos-shell ··· 70 88 git remote add local-dev git@nixos-shell:user/repo 71 89 git push local-dev main 72 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 191 ``` 192 192 193 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 13 14 14 ### the engine 15 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. 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. 19 20 20 21 The base image for the container is constructed on the fly using 21 22 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
+12 -3
docs/spindle/hosting.md
··· 31 31 2. **Build the Spindle binary.** 32 32 33 33 ```shell 34 - go build -o spindle core/spindle/server.go 34 + cd core 35 + go mod download 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 35 44 ``` 36 45 37 - 3. **Run the Spindle binary.** 46 + 4. **Run the Spindle binary.** 38 47 39 48 ```shell 40 - ./spindle 49 + ./cmd/spindle/spindle 41 50 ``` 42 51 43 52 Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
+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 57 depth: 50 58 58 submodules: true 59 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 20 "type": "github" 21 21 } 22 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 + }, 23 62 "htmx-src": { 24 63 "flake": false, 25 64 "locked": { ··· 101 140 }, 102 141 "nixpkgs": { 103 142 "locked": { 104 - "lastModified": 1746904237, 105 - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", 143 + "lastModified": 1751984180, 144 + "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 106 145 "owner": "nixos", 107 146 "repo": "nixpkgs", 108 - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", 147 + "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 109 148 "type": "github" 110 149 }, 111 150 "original": { ··· 118 157 "root": { 119 158 "inputs": { 120 159 "gitignore": "gitignore", 160 + "gomod2nix": "gomod2nix", 121 161 "htmx-src": "htmx-src", 122 162 "htmx-ws-src": "htmx-ws-src", 123 163 "ibm-plex-mono-src": "ibm-plex-mono-src", ··· 139 179 "original": { 140 180 "type": "tarball", 141 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" 142 197 } 143 198 } 144 199 },
+94 -55
flake.nix
··· 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 + gomod2nix = { 7 + url = "github:nix-community/gomod2nix"; 8 + inputs.nixpkgs.follows = "nixpkgs"; 9 + }; 6 10 indigo = { 7 11 url = "github:oppiliappan/indigo"; 8 12 flake = false; ··· 42 46 outputs = { 43 47 self, 44 48 nixpkgs, 49 + gomod2nix, 45 50 indigo, 46 51 htmx-src, 47 52 htmx-ws-src, ··· 53 58 }: let 54 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 56 - nixpkgsFor = forAllSystems (system: 57 - import nixpkgs { 58 - inherit system; 59 - overlays = [self.overlays.default]; 60 - }); 61 - inherit (gitignore.lib) gitignoreSource; 62 - in { 63 - overlays.default = final: prev: let 64 - goModHash = "sha256-SLi+nALwCd/Lzn3aljwPqCo2UaM9hl/4OAjcHQLt2Bk="; 65 - appviewDeps = { 66 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource; 67 - }; 68 - knotDeps = { 69 - inherit goModHash gitignoreSource; 70 - }; 71 - spindleDeps = { 72 - inherit goModHash gitignoreSource; 73 - }; 74 - mkPackageSet = pkgs: { 75 - lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 76 - appview = pkgs.callPackage ./nix/pkgs/appview.nix appviewDeps; 77 - knot = pkgs.callPackage ./nix/pkgs/knot.nix {}; 78 - spindle = pkgs.callPackage ./nix/pkgs/spindle.nix spindleDeps; 79 - knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix knotDeps; 80 - sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix { 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 { 81 72 inherit (pkgs) gcc; 82 73 inherit sqlite-lib-src; 83 74 }; 84 - genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;}; 85 - }; 86 - in 87 - mkPackageSet final; 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 88 89 89 packages = forAllSystems (system: let 90 90 pkgs = nixpkgsFor.${system}; 91 - staticPkgs = pkgs.pkgsStatic; 92 - crossPkgs = pkgs.pkgsCross.gnu64.pkgsStatic; 91 + packages = mkPackageSet pkgs; 92 + staticPackages = mkPackageSet pkgs.pkgsStatic; 93 + crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 93 94 in { 94 - appview = pkgs.appview; 95 - lexgen = pkgs.lexgen; 96 - knot = pkgs.knot; 97 - knot-unwrapped = pkgs.knot-unwrapped; 98 - spindle = pkgs.spindle; 99 - genjwks = pkgs.genjwks; 100 - sqlite-lib = pkgs.sqlite-lib; 95 + appview = packages.appview; 96 + lexgen = packages.lexgen; 97 + knot = packages.knot; 98 + knot-unwrapped = packages.knot-unwrapped; 99 + spindle = packages.spindle; 100 + genjwks = packages.genjwks; 101 + sqlite-lib = packages.sqlite-lib; 101 102 102 - pkgsStatic-appview = staticPkgs.appview; 103 - pkgsStatic-knot = staticPkgs.knot; 104 - pkgsStatic-knot-unwrapped = staticPkgs.knot-unwrapped; 105 - pkgsStatic-spindle = staticPkgs.spindle; 106 - pkgsStatic-sqlite-lib = staticPkgs.sqlite-lib; 103 + pkgsStatic-appview = staticPackages.appview; 104 + pkgsStatic-knot = staticPackages.knot; 105 + pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 106 + pkgsStatic-spindle = staticPackages.spindle; 107 + pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 107 108 108 - pkgsCross-gnu64-pkgsStatic-appview = crossPkgs.appview; 109 - pkgsCross-gnu64-pkgsStatic-knot = crossPkgs.knot; 110 - pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPkgs.knot-unwrapped; 111 - pkgsCross-gnu64-pkgsStatic-spindle = crossPkgs.spindle; 109 + pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 110 + pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 111 + pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 112 + pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 112 113 }); 113 - defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview); 114 - formatter = forAllSystems (system: nixpkgsFor."${system}".alejandra); 114 + defaultPackage = forAllSystems (system: self.packages.${system}.appview); 115 + formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 115 116 devShells = forAllSystems (system: let 116 117 pkgs = nixpkgsFor.${system}; 118 + packages' = self.packages.${system}; 117 119 staticShell = pkgs.mkShell.override { 118 120 stdenv = pkgs.pkgsStatic.stdenv; 119 121 }; ··· 124 126 pkgs.air 125 127 pkgs.gopls 126 128 pkgs.httpie 127 - pkgs.lexgen 128 129 pkgs.litecli 129 130 pkgs.websocat 130 131 pkgs.tailwindcss 131 132 pkgs.nixos-shell 132 133 pkgs.redis 134 + packages'.lexgen 133 135 ]; 134 136 shellHook = '' 135 137 mkdir -p appview/pages/static/{fonts,icons} ··· 139 141 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 140 142 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 141 143 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 142 - export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)" 144 + export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 143 145 ''; 144 146 env.CGO_ENABLED = 1; 145 147 }; ··· 151 153 '' 152 154 ${pkgs.air}/bin/air -c /dev/null \ 153 155 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 154 - -build.bin "./out/${name}.out ${arg}" \ 156 + -build.bin "./out/${name}.out" \ 157 + -build.args_bin "${arg}" \ 155 158 -build.stop_on_error "true" \ 156 159 -build.include_ext "go" 157 160 ''; ··· 173 176 type = "app"; 174 177 program = ''${tailwind-watcher}/bin/run''; 175 178 }; 179 + vm = { 180 + type = "app"; 181 + program = toString (pkgs.writeShellScript "vm" '' 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 + }; 176 191 }); 177 192 178 - nixosModules.appview = import ./nix/modules/appview.nix {inherit self;}; 179 - nixosModules.knot = import ./nix/modules/knot.nix {inherit self;}; 180 - nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;}; 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 + }; 181 220 nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 182 221 }; 183 222 }
+54 -34
go.mod
··· 1 1 module tangled.sh/tangled.sh/core 2 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 3 + go 1.24.4 6 4 7 5 require ( 8 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 9 8 github.com/alecthomas/chroma/v2 v2.15.0 9 + github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 15 16 github.com/cyphar/filepath-securejoin v0.4.1 16 17 github.com/dgraph-io/ristretto v0.2.0 17 18 github.com/docker/docker v28.2.2+incompatible ··· 22 23 github.com/go-git/go-git/v5 v5.14.0 23 24 github.com/google/uuid v1.6.0 24 25 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 26 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 27 github.com/hiddeco/sshsig v0.2.0 27 28 github.com/hpcloud/tail v1.0.0 28 29 github.com/ipfs/go-cid v0.5.0 29 30 github.com/lestrrat-go/jwx/v2 v2.1.6 30 31 github.com/mattn/go-sqlite3 v1.14.24 31 32 github.com/microcosm-cc/bluemonday v1.0.27 33 + github.com/openbao/openbao/api/v2 v2.3.0 32 34 github.com/posthog/posthog-go v1.5.5 33 - github.com/redis/go-redis/v9 v9.3.0 35 + github.com/redis/go-redis/v9 v9.7.3 34 36 github.com/resend/resend-go/v2 v2.15.0 35 37 github.com/sethvargo/go-envconfig v1.1.0 36 38 github.com/stretchr/testify v1.10.0 37 39 github.com/urfave/cli/v3 v3.3.3 38 40 github.com/whyrusleeping/cbor-gen v0.3.1 39 41 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/crypto v0.40.0 43 + golang.org/x/net v0.42.0 44 + golang.org/x/sync v0.16.0 42 45 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 43 46 gopkg.in/yaml.v3 v3.0.1 44 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 47 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 45 48 ) 46 49 47 50 require ( 48 51 dario.cat/mergo v1.0.1 // indirect 49 52 github.com/Microsoft/go-winio v0.6.2 // indirect 50 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 53 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 54 + github.com/alecthomas/repr v0.4.0 // indirect 51 55 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 52 - github.com/avast/retry-go/v4 v4.6.1 // indirect 53 56 github.com/aymerick/douceur v0.2.0 // indirect 54 57 github.com/beorn7/perks v1.0.1 // indirect 55 58 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 59 github.com/casbin/govaluate v1.3.0 // indirect 60 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 57 61 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 62 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 59 63 github.com/containerd/errdefs v1.0.0 // indirect 60 64 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 65 github.com/containerd/log v0.1.0 // indirect ··· 68 72 github.com/docker/go-units v0.5.0 // indirect 69 73 github.com/emirpasic/gods v1.18.1 // indirect 70 74 github.com/felixge/httpsnoop v1.0.4 // indirect 75 + github.com/fsnotify/fsnotify v1.6.0 // indirect 71 76 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 77 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 78 github.com/go-git/go-billy/v5 v5.6.2 // indirect 74 - github.com/go-logr/logr v1.4.2 // indirect 79 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 80 + github.com/go-logr/logr v1.4.3 // indirect 75 81 github.com/go-logr/stdr v1.2.2 // indirect 76 82 github.com/go-redis/cache/v9 v9.0.0 // indirect 83 + github.com/go-test/deep v1.1.1 // indirect 77 84 github.com/goccy/go-json v0.10.5 // indirect 78 85 github.com/gogo/protobuf v1.3.2 // indirect 79 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 86 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 80 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 81 90 github.com/gorilla/css v1.0.1 // indirect 82 91 github.com/gorilla/securecookie v1.1.2 // indirect 92 + github.com/hashicorp/errwrap v1.1.0 // indirect 83 93 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 - github.com/hashicorp/go-retryablehttp v0.7.7 // 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 85 99 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 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 87 103 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 104 + github.com/ipfs/boxo v0.33.0 // indirect 105 + github.com/ipfs/go-block-format v0.2.2 // indirect 90 106 github.com/ipfs/go-datastore v0.8.2 // indirect 91 107 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 92 108 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 109 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 110 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 95 111 github.com/ipfs/go-log v1.0.5 // indirect 96 112 github.com/ipfs/go-log/v2 v2.6.0 // indirect 97 113 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 98 114 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 115 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 116 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 117 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 102 118 github.com/lestrrat-go/httpcc v1.0.1 // indirect 103 119 github.com/lestrrat-go/httprc v1.0.6 // indirect 104 120 github.com/lestrrat-go/iter v1.0.2 // indirect 105 121 github.com/lestrrat-go/option v1.0.1 // indirect 106 122 github.com/mattn/go-isatty v0.0.20 // indirect 107 123 github.com/minio/sha256-simd v1.0.1 // indirect 124 + github.com/mitchellh/mapstructure v1.5.0 // indirect 108 125 github.com/moby/docker-image-spec v1.3.1 // indirect 109 126 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 127 github.com/moby/term v0.5.2 // indirect ··· 116 133 github.com/multiformats/go-multihash v0.2.3 // indirect 117 134 github.com/multiformats/go-varint v0.0.7 // indirect 118 135 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 136 + github.com/onsi/gomega v1.37.0 // indirect 119 137 github.com/opencontainers/go-digest v1.0.0 // indirect 120 138 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 139 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 122 140 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 141 github.com/pkg/errors v0.9.1 // indirect 124 142 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 125 143 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 126 144 github.com/prometheus/client_golang v1.22.0 // indirect 127 145 github.com/prometheus/client_model v0.6.2 // indirect 128 - github.com/prometheus/common v0.63.0 // indirect 146 + github.com/prometheus/common v0.64.0 // indirect 129 147 github.com/prometheus/procfs v0.16.1 // indirect 148 + github.com/ryanuber/go-glob v1.0.0 // indirect 130 149 github.com/segmentio/asm v1.2.0 // indirect 131 150 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 151 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 136 155 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 137 156 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 138 157 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 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 143 163 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 144 164 go.uber.org/atomic v1.11.0 // indirect 145 165 go.uber.org/multierr v1.11.0 // indirect 146 166 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 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 154 174 google.golang.org/protobuf v1.36.6 // indirect 155 175 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 176 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129 -87
go.sum
··· 7 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 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= 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 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 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= 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 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 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= 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= 56 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 91 93 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 92 94 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 93 95 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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 97 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 96 98 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 97 99 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 98 100 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 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= 101 104 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 102 105 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 103 106 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 114 117 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 115 118 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 116 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= 117 122 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 118 123 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= 124 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 125 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 121 126 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 122 127 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 123 128 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 124 129 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 125 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= 126 133 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 127 134 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 128 135 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 129 136 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 130 137 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= 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= 133 140 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 134 141 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 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= 137 145 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 138 146 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 139 147 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 146 154 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 155 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 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= 149 158 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 159 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 160 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 161 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 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= 154 165 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 166 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 167 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 166 177 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 167 178 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 168 179 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= 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= 171 182 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 172 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= 173 187 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 174 188 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 175 189 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 176 190 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= 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= 179 201 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 180 202 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 181 203 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 182 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= 183 207 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 184 208 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 185 209 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 189 213 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 190 214 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 191 215 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= 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= 196 220 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 197 221 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 198 222 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 205 229 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 206 230 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 207 231 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= 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= 212 236 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 213 237 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 214 238 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 216 240 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 217 241 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 218 242 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 243 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 222 244 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 223 245 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 229 251 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 230 252 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 231 253 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= 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= 234 256 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 235 257 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 236 258 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 239 261 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 240 262 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 241 263 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= 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= 244 266 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 245 267 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 246 268 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 251 273 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 252 274 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 253 275 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= 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= 260 278 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 261 279 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 262 280 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 265 283 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 284 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 267 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= 268 288 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 269 289 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 270 290 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 281 301 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 282 302 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 283 303 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 304 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 287 305 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 306 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 307 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 308 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 318 334 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 335 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 336 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= 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= 323 341 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 324 342 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 325 343 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 326 344 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 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= 329 348 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 330 349 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 331 350 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 346 365 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 347 366 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 348 367 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= 368 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 369 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 351 370 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 352 371 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 353 372 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= 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= 356 375 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 357 376 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 358 377 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 360 379 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 361 380 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 362 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= 363 384 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 364 385 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 365 386 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 404 425 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 405 426 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 406 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= 407 429 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 408 430 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 409 431 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 413 435 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 414 436 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 415 437 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= 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= 422 444 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 423 445 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= 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= 432 454 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 433 455 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 434 456 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 451 473 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 452 474 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 453 475 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= 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= 458 481 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 459 482 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 460 483 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 461 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= 462 486 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 463 487 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 464 488 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 465 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= 466 491 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 467 492 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 468 493 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 471 496 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 472 497 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 473 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= 474 500 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 475 501 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 476 502 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 480 506 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 481 507 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 482 508 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= 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= 485 513 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 486 514 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 515 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 489 517 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 518 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 519 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= 520 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 521 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 494 522 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 495 523 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 496 524 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 502 530 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 531 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 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= 505 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= 506 536 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 537 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 538 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 510 540 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 541 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 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= 513 544 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 545 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 546 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 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= 517 549 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= 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= 520 554 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 521 555 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 522 556 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 523 557 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 524 558 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 525 559 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= 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= 528 565 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 529 566 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 530 567 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 532 569 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 533 570 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 534 571 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= 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= 539 579 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 580 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 581 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 547 587 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 548 588 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 549 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= 550 591 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 551 592 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 552 593 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 553 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= 554 596 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 597 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 556 598 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 557 599 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 558 600 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 559 601 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= 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= 566 608 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 567 609 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 568 610 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 599 641 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 600 642 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 601 643 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= 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= 604 646 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 605 647 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20 -4
guard/guard.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 6 8 "log/slog" 7 9 "net/http" 8 10 "net/url" ··· 13 15 "github.com/bluesky-social/indigo/atproto/identity" 14 16 securejoin "github.com/cyphar/filepath-securejoin" 15 17 "github.com/urfave/cli/v3" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 + "tangled.sh/tangled.sh/core/idresolver" 17 19 "tangled.sh/tangled.sh/core/log" 18 20 ) 19 21 ··· 43 45 Usage: "internal API endpoint", 44 46 Value: "http://localhost:5444", 45 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 46 53 }, 47 54 } 48 55 } ··· 54 61 gitDir := cmd.String("git-dir") 55 62 logPath := cmd.String("log-path") 56 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 57 65 58 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 67 if err != nil { ··· 149 157 "fullPath", fullPath, 150 158 "client", clientIP) 151 159 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 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") 154 166 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 156 171 } 172 + io.Copy(os.Stderr, motdReader) 157 173 158 174 gitCmd := exec.Command(gitCommand, fullPath) 159 175 gitCmd.Stdout = os.Stdout
+24
hook/hook.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 "net/http" 8 9 "os" ··· 10 11 11 12 "github.com/urfave/cli/v3" 12 13 ) 14 + 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 13 18 14 19 // The hook command is nested like so: 15 20 // ··· 36 41 Usage: "endpoint for the internal API", 37 42 Value: "http://localhost:5444", 38 43 }, 44 + &cli.StringSliceFlag{ 45 + Name: "push-option", 46 + Usage: "any push option from git", 47 + }, 39 48 }, 40 49 Commands: []*cli.Command{ 41 50 { ··· 52 61 userDid := cmd.String("user-did") 53 62 userHandle := cmd.String("user-handle") 54 63 endpoint := cmd.String("internal-api") 64 + pushOptions := cmd.StringSlice("push-option") 55 65 56 66 payloadReader := bufio.NewReader(os.Stdin) 57 67 payload, _ := payloadReader.ReadString('\n') ··· 67 77 req.Header.Set("X-Git-Dir", gitDir) 68 78 req.Header.Set("X-Git-User-Did", userDid) 69 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 + } 70 85 71 86 resp, err := client.Do(req) 72 87 if err != nil { ··· 76 91 77 92 if resp.StatusCode != http.StatusOK { 78 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) 79 103 } 80 104 81 105 return nil
+6 -1
hook/setup.go
··· 133 133 134 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 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 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 137 142 `, executablePath, config.internalApi) 138 143 139 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 100 101 101 .prose img { 102 102 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 103 + margin: 0; 105 104 vertical-align: middle; 106 105 } 107 106 }
+13
jetstream/jetstream.go
··· 52 52 j.mu.Unlock() 53 53 } 54 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 + 55 68 type processor func(context.Context, *models.Event) error 56 69 57 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 ··· 23 25 24 26 // This disables signature verification so use with caution. 25 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)) 26 32 } 27 33 28 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 6 "fmt" 7 7 "io" 8 8 "io/fs" 9 - "os/exec" 10 9 "path" 11 - "sort" 12 10 "strconv" 13 11 "strings" 14 12 "time" ··· 16 14 "github.com/go-git/go-git/v5" 17 15 "github.com/go-git/go-git/v5/plumbing" 18 16 "github.com/go-git/go-git/v5/plumbing/object" 19 - "tangled.sh/tangled.sh/core/types" 20 17 ) 21 18 22 19 var ( ··· 170 167 return count, nil 171 168 } 172 169 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 170 func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { 193 171 return g.r.CommitObject(h) 194 172 } ··· 285 263 return io.ReadAll(reader) 286 264 } 287 265 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 266 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 355 267 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 356 268 if err != nil { ··· 370 282 } 371 283 372 284 func (g *GitRepo) FindMainBranch() (string, error) { 373 - ref, err := g.r.Head() 285 + output, err := g.revParse("--abbrev-ref", "HEAD") 374 286 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 287 + return "", fmt.Errorf("failed to find main branch: %w", err) 379 288 } 380 289 381 - return "", fmt.Errorf("unable to find main branch: %w", err) 290 + return strings.TrimSpace(string(output)), nil 382 291 } 383 292 384 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 2 3 3 import ( 4 4 "bufio" 5 + "context" 5 6 "fmt" 6 7 "io" 7 8 "strings" 9 + "time" 8 10 9 11 "tangled.sh/tangled.sh/core/api/tangled" 10 12 ··· 46 48 } 47 49 48 50 type RefUpdateMeta struct { 49 - CommitCount CommitCount 50 - IsDefaultRef bool 51 + CommitCount CommitCount 52 + IsDefaultRef bool 53 + LangBreakdown LangBreakdown 51 54 } 52 55 53 56 type CommitCount struct { ··· 57 60 func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 58 61 commitCount, err := g.newCommitCount(line) 59 62 if err != nil { 60 - // TODO: non-fatal, log this 63 + // TODO: log this 61 64 } 62 65 63 66 isDefaultRef, err := g.isDefaultBranch(line) 64 67 if err != nil { 65 - // TODO: non-fatal, log this 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 66 76 } 67 77 68 78 return RefUpdateMeta{ 69 - CommitCount: commitCount, 70 - IsDefaultRef: isDefaultRef, 79 + CommitCount: commitCount, 80 + IsDefaultRef: isDefaultRef, 81 + LangBreakdown: breakdown, 71 82 } 72 83 } 73 84 ··· 77 88 ByEmail: byEmail, 78 89 } 79 90 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 - } 91 + if line.NewSha.IsZero() { 92 + return commitCount, nil 93 + } 88 94 89 - lines := strings.Split(strings.TrimSpace(string(output)), "\n") 90 - if len(lines) == 1 && lines[0] == "" { 91 - return commitCount, nil 92 - } 95 + args := []string{fmt.Sprintf("--max-count=%d", 100)} 93 96 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 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 100 119 } 120 + commitCount.ByEmail[obj.Author.Email] += 1 101 121 } 102 122 103 123 return commitCount, nil ··· 126 146 }) 127 147 } 128 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 + 129 160 return tangled.GitRefUpdate_Meta{ 130 161 CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 131 162 ByEmail: byEmail, 132 163 }, 133 - IsDefaultRef: m.IsDefaultRef, 164 + IsDefaultRef: m.IsDefaultRef, 165 + LangBreakdown: langBreakdown, 134 166 } 135 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 8 "runtime/debug" 9 9 10 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/idresolver" 11 12 "tangled.sh/tangled.sh/core/jetstream" 12 13 "tangled.sh/tangled.sh/core/knotserver/config" 13 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 14 17 "tangled.sh/tangled.sh/core/notifier" 15 18 "tangled.sh/tangled.sh/core/rbac" 16 - ) 17 - 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 19 ) 21 20 22 21 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 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 29 30 30 // init is a channel that is closed when the knot has been initailized 31 31 // i.e. when the first user (knot owner) has been added. ··· 37 37 r := chi.NewRouter() 38 38 39 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{}), 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{}), 47 48 } 48 49 49 - err := e.AddKnot(ThisServer) 50 + err := e.AddKnot(rbac.ThisServer) 50 51 if err != nil { 51 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 52 53 } ··· 131 132 }) 132 133 }) 133 134 135 + // xrpc apis 136 + r.Mount("/xrpc", h.XrpcRouter()) 137 + 134 138 // Create a new repository. 135 139 r.Route("/repo", func(r chi.Router) { 136 140 r.Use(h.VerifySignature) ··· 161 165 r.Get("/keys", h.Keys) 162 166 163 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() 164 183 } 165 184 166 185 // version is set during build time.
+65 -4
knotserver/ingester.go
··· 17 17 "github.com/bluesky-social/jetstream/pkg/models" 18 18 securejoin "github.com/cyphar/filepath-securejoin" 19 19 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 23 "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/rbac" 24 25 "tangled.sh/tangled.sh/core/workflow" 25 26 ) 26 27 ··· 46 47 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 47 48 } 48 49 49 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 50 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 50 51 if err != nil || !ok { 51 52 l.Error("failed to add member", "did", did) 52 53 return fmt.Errorf("failed to enforce permissions: %w", err) 53 54 } 54 55 55 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 56 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 56 57 l.Error("failed to add member", "error", err) 57 58 return fmt.Errorf("failed to add member: %w", err) 58 59 } ··· 212 213 return h.db.InsertEvent(event, h.n) 213 214 } 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 + 215 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 216 267 l := log.FromContext(ctx) 217 268 ··· 265 316 defer func() { 266 317 eventTime := event.TimeUS 267 318 lastTimeUs := eventTime + 1 268 - fmt.Println("lastTimeUs", lastTimeUs) 269 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 270 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 271 321 } ··· 291 341 if err := h.processKnotMember(ctx, did, record); err != nil { 292 342 return fmt.Errorf("failed to process knot member: %w", err) 293 343 } 344 + 294 345 case tangled.RepoPullNSID: 295 346 var record tangled.RepoPull 296 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 299 350 if err := h.processPull(ctx, did, record); err != nil { 300 351 return fmt.Errorf("failed to process knot member: %w", err) 301 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 + 302 363 } 303 364 304 365 return err
+66 -7
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "log/slog" 7 8 "net/http" 8 9 "path/filepath" ··· 12 13 "github.com/go-chi/chi/v5" 13 14 "github.com/go-chi/chi/v5/middleware" 14 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/hook" 15 17 "tangled.sh/tangled.sh/core/knotserver/config" 16 18 "tangled.sh/tangled.sh/core/knotserver/db" 17 19 "tangled.sh/tangled.sh/core/knotserver/git" ··· 37 39 return 38 40 } 39 41 40 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 42 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 41 43 if err != nil || !ok { 42 44 w.WriteHeader(http.StatusForbidden) 43 45 return ··· 63 65 return 64 66 } 65 67 68 + type PushOptions struct { 69 + skipCi bool 70 + verboseCi bool 71 + } 72 + 66 73 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 67 74 l := h.l.With("handler", "PostReceiveHook") 68 75 ··· 89 96 // non-fatal 90 97 } 91 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 + 92 115 for _, line := range lines { 93 116 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 94 117 if err != nil { ··· 96 119 // non-fatal 97 120 } 98 121 99 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 122 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 100 123 if err != nil { 101 124 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 102 125 // non-fatal 103 126 } 104 127 } 128 + 129 + writeJSON(w, resp) 105 130 } 106 131 107 132 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 115 140 return err 116 141 } 117 142 118 - gr, err := git.PlainOpen(repoPath) 143 + gr, err := git.Open(repoPath, line.Ref) 119 144 if err != nil { 120 - return err 145 + return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 121 146 } 122 147 123 148 meta := gr.RefUpdateMeta(line) 149 + 124 150 metaRecord := meta.AsRecord() 125 151 126 152 refUpdate := tangled.GitRefUpdate{ ··· 146 172 return h.db.InsertEvent(event, h.n) 147 173 } 148 174 149 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 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 + 150 180 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 151 181 if err != nil { 152 182 return err ··· 166 196 if err != nil { 167 197 return err 168 198 } 199 + 200 + pipelineParseErrors := []string{} 169 201 170 202 var pipeline workflow.Pipeline 171 203 for _, e := range workflowDir { ··· 181 213 182 214 wf, err := workflow.FromFile(e.Name, contents) 183 215 if err != nil { 184 - // TODO: log here, respond to client that is pushing 185 216 h.l.Error("failed to parse workflow", "err", err, "path", fpath) 217 + pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 186 218 continue 187 219 } 188 220 ··· 207 239 }, 208 240 } 209 241 210 - // TODO: send the diagnostics back to the user here via stderr 211 242 cp := compiler.Compile(pipeline) 212 243 eventJson, err := json.Marshal(cp) 213 244 if err != nil { 214 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 + } 215 274 } 216 275 217 276 // do not run empty pipelines
+38 -70
knotserver/routes.go
··· 13 13 "net/http" 14 14 "net/url" 15 15 "os" 16 - "path" 17 16 "path/filepath" 18 17 "strconv" 19 18 "strings" ··· 23 22 securejoin "github.com/cyphar/filepath-securejoin" 24 23 "github.com/gliderlabs/ssh" 25 24 "github.com/go-chi/chi/v5" 26 - "github.com/go-enry/go-enry/v2" 27 25 gogit "github.com/go-git/go-git/v5" 28 26 "github.com/go-git/go-git/v5/plumbing" 29 27 "github.com/go-git/go-git/v5/plumbing/object" ··· 31 29 "tangled.sh/tangled.sh/core/knotserver/db" 32 30 "tangled.sh/tangled.sh/core/knotserver/git" 33 31 "tangled.sh/tangled.sh/core/patchutil" 32 + "tangled.sh/tangled.sh/core/rbac" 34 33 "tangled.sh/tangled.sh/core/types" 35 34 ) 36 35 ··· 96 95 total int 97 96 branches []types.Branch 98 97 files []types.NiceTree 99 - tags []*git.TagReference 98 + tags []object.Tag 100 99 ) 101 100 102 101 var wg sync.WaitGroup ··· 169 168 170 169 rtags := []*types.TagReference{} 171 170 for _, tag := range tags { 171 + var target *object.Tag 172 + if tag.Target != plumbing.ZeroHash { 173 + target = &tag 174 + } 172 175 tr := types.TagReference{ 173 - Tag: tag.TagObject(), 176 + Tag: target, 174 177 } 175 178 176 179 tr.Reference = types.Reference{ 177 - Name: tag.Name(), 178 - Hash: tag.Hash().String(), 180 + Name: tag.Name, 181 + Hash: tag.Hash.String(), 179 182 } 180 183 181 - if tag.Message() != "" { 182 - tr.Message = tag.Message() 184 + if tag.Message != "" { 185 + tr.Message = tag.Message 183 186 } 184 187 185 188 rtags = append(rtags, &tr) ··· 283 286 mimeType = "image/svg+xml" 284 287 } 285 288 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 + // 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) 289 300 return 290 301 } 291 302 ··· 488 499 489 500 rtags := []*types.TagReference{} 490 501 for _, tag := range tags { 502 + var target *object.Tag 503 + if tag.Target != plumbing.ZeroHash { 504 + target = &tag 505 + } 491 506 tr := types.TagReference{ 492 - Tag: tag.TagObject(), 507 + Tag: target, 493 508 } 494 509 495 510 tr.Reference = types.Reference{ 496 - Name: tag.Name(), 497 - Hash: tag.Hash().String(), 511 + Name: tag.Name, 512 + Hash: tag.Hash.String(), 498 513 } 499 514 500 - if tag.Message() != "" { 501 - tr.Message = tag.Message() 515 + if tag.Message != "" { 516 + tr.Message = tag.Message 502 517 } 503 518 504 519 rtags = append(rtags, &tr) ··· 668 683 } 669 684 670 685 // add perms for this user to access the repo 671 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 686 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 672 687 if err != nil { 673 688 l.Error("adding repo permissions", "error", err.Error()) 674 689 writeError(w, err.Error(), http.StatusInternalServerError) ··· 777 792 return 778 793 } 779 794 780 - sizes := make(map[string]int64) 781 - 782 795 ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 783 796 defer cancel() 784 797 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 - }) 798 + sizes, err := gr.AnalyzeLanguages(ctx) 812 799 if err != nil { 813 - l.Error("failed to recurse file tree", "error", err.Error()) 800 + l.Error("failed to analyze languages", "error", err.Error()) 814 801 writeError(w, err.Error(), http.StatusNoContent) 815 802 return 816 803 } ··· 818 805 resp := types.RepoLanguageResponse{Languages: sizes} 819 806 820 807 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 808 } 841 809 842 810 func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { ··· 933 901 } 934 902 935 903 // add perms for this user to access the repo 936 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 904 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 937 905 if err != nil { 938 906 l.Error("adding repo permissions", "error", err.Error()) 939 907 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1187 1155 } 1188 1156 h.jc.AddDid(did) 1189 1157 1190 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1158 + if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1191 1159 l.Error("adding member", "error", err.Error()) 1192 1160 writeError(w, err.Error(), http.StatusInternalServerError) 1193 1161 return ··· 1225 1193 h.jc.AddDid(data.Did) 1226 1194 1227 1195 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1228 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1196 + if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1229 1197 l.Error("adding repo collaborator", "error", err.Error()) 1230 1198 writeError(w, err.Error(), http.StatusInternalServerError) 1231 1199 return ··· 1322 1290 } 1323 1291 h.jc.AddDid(data.Did) 1324 1292 1325 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1293 + if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1326 1294 l.Error("adding owner", "error", err.Error()) 1327 1295 writeError(w, err.Error(), http.StatusInternalServerError) 1328 1296 return
+1
knotserver/server.go
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
-5
knotserver/util.go
··· 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "github.com/go-chi/chi/v5" 11 - "github.com/microcosm-cc/bluemonday" 12 11 ) 13 - 14 - func sanitize(content []byte) []byte { 15 - return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 16 - } 17 12 18 13 func didPath(r *http.Request) string { 19 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 61 "type": "boolean", 62 62 "default": "false" 63 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 + }, 64 76 "commitCount": { 65 77 "type": "object", 66 78 "required": [], ··· 87 99 } 88 100 } 89 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" 90 117 } 91 118 } 92 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}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 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"; 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 + }; 24 30 }; 25 31 }; 26 - }; 27 32 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 33 + config = mkIf cfg.enable { 34 + systemd.services.tangled-appview = { 35 + description = "tangled appview service"; 36 + wantedBy = ["multi-user.target"]; 32 37 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 + serviceConfig = { 39 + ListenStream = "0.0.0.0:${toString cfg.port}"; 40 + ExecStart = "${cfg.package}/bin/appview"; 41 + Restart = "always"; 42 + }; 38 43 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 44 + environment = { 45 + TANGLED_DB_PATH = "appview.db"; 46 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 47 + }; 42 48 }; 43 49 }; 44 - }; 45 - } 50 + }
+45 -7
nix/modules/knot.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 3 pkgs, 4 4 lib, ··· 13 13 type = types.bool; 14 14 default = false; 15 15 description = "Enable a tangled knot"; 16 + }; 17 + 18 + package = mkOption { 19 + type = types.package; 20 + description = "Package to use for the knot"; 16 21 }; 17 22 18 23 appviewEndpoint = mkOption { ··· 53 58 }; 54 59 }; 55 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 + 56 83 server = { 57 84 listenAddr = mkOption { 58 85 type = types.str; ··· 94 121 }; 95 122 96 123 config = mkIf cfg.enable { 97 - environment.systemPackages = with pkgs; [ 98 - git 99 - self.packages."${pkgs.system}".knot 124 + environment.systemPackages = [ 125 + pkgs.git 126 + cfg.package 100 127 ]; 101 128 102 - system.activationScripts.gitConfig = '' 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 '' 103 138 mkdir -p "${cfg.repo.scanPath}" 104 139 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 105 140 ··· 108 143 [user] 109 144 name = Git User 110 145 email = git@example.com 146 + [receive] 147 + advertisePushOptions = true 111 148 EOF 149 + ${setMotd} 112 150 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 113 151 ''; 114 152 ··· 135 173 mode = "0555"; 136 174 text = '' 137 175 #!${pkgs.stdenv.shell} 138 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 176 + ${cfg.package}/bin/knot keys \ 139 177 -output authorized-keys \ 140 178 -internal-api "http://${cfg.server.internalListenAddr}" \ 141 179 -git-dir "${cfg.repo.scanPath}" \ ··· 160 198 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 161 199 ]; 162 200 EnvironmentFile = cfg.server.secretFile; 163 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 201 + ExecStart = "${cfg.package}/bin/knot server"; 164 202 Restart = "always"; 165 203 }; 166 204 };
+8 -5
nix/modules/spindle.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 5 }: let ··· 13 12 type = types.bool; 14 13 default = false; 15 14 description = "Enable a tangled spindle"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the spindle"; 16 19 }; 17 20 18 21 server = { ··· 60 63 description = "Nixery instance to use"; 61 64 }; 62 65 63 - stepTimeout = mkOption { 66 + workflowTimeout = mkOption { 64 67 type = types.str; 65 68 default = "5m"; 66 69 description = "Timeout for each step of a pipeline"; ··· 87 90 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 88 91 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 89 92 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 - "SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}" 93 + "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 91 94 ]; 92 - ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 95 + ExecStart = "${cfg.package}/bin/spindle"; 93 96 Restart = "always"; 94 97 }; 95 98 };
+6 -9
nix/pkgs/appview.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 htmx-src, 5 5 htmx-ws-src, 6 6 lucide-src, ··· 8 8 ibm-plex-mono-src, 9 9 tailwindcss, 10 10 sqlite-lib, 11 - goModHash, 12 11 gitignoreSource, 13 12 }: 14 - buildGoModule { 15 - inherit stdenv; 16 - 13 + buildGoApplication { 17 14 pname = "appview"; 18 15 version = "0.1.0"; 19 16 src = gitignoreSource ../..; 17 + inherit modules; 20 18 21 19 postUnpack = '' 22 20 pushd source ··· 33 31 34 32 doCheck = false; 35 33 subPackages = ["cmd/appview"]; 36 - vendorHash = goModHash; 37 34 38 - tags = "libsqlite3"; 35 + tags = ["libsqlite3"]; 39 36 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 40 37 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 41 - env.CGO_ENABLED = 1; 38 + CGO_ENABLED = 1; 42 39 }
+5 -5
nix/pkgs/genjwks.nix
··· 1 1 { 2 - buildGoModule, 3 - goModHash, 4 2 gitignoreSource, 3 + buildGoApplication, 4 + modules, 5 5 }: 6 - buildGoModule { 6 + buildGoApplication { 7 7 pname = "genjwks"; 8 8 version = "0.1.0"; 9 9 src = gitignoreSource ../..; 10 + inherit modules; 10 11 subPackages = ["cmd/genjwks"]; 11 - vendorHash = goModHash; 12 12 doCheck = false; 13 - env.CGO_ENABLED = 0; 13 + CGO_ENABLED = 0; 14 14 }
+6 -7
nix/pkgs/knot-unwrapped.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 5 gitignoreSource, 7 6 }: 8 - buildGoModule { 7 + buildGoApplication { 9 8 pname = "knot"; 10 9 version = "0.1.0"; 11 10 src = gitignoreSource ../..; 11 + inherit modules; 12 12 13 13 doCheck = false; 14 14 15 15 subPackages = ["cmd/knot"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 16 + tags = ["libsqlite3"]; 18 17 19 18 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 19 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 20 + CGO_ENABLED = 1; 22 21 }
+6 -7
nix/pkgs/spindle.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 5 gitignoreSource, 7 6 }: 8 - buildGoModule { 7 + buildGoApplication { 9 8 pname = "spindle"; 10 9 version = "0.1.0"; 11 10 src = gitignoreSource ../..; 11 + inherit modules; 12 12 13 13 doCheck = false; 14 14 15 15 subPackages = ["cmd/spindle"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 16 + tags = ["libsqlite3"]; 18 17 19 18 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 19 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 20 + CGO_ENABLED = 1; 22 21 }
+1
nix/vm.nix
··· 48 48 ]; 49 49 services.tangled-knot = { 50 50 enable = true; 51 + motd = "Welcome to the development knot!\n"; 51 52 server = { 52 53 secretFile = "/var/lib/knot/secret"; 53 54 hostname = "localhost:6000";
+25
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/types" 8 9 ) 9 10 10 11 type InterdiffResult struct { ··· 33 34 *gitdiff.File 34 35 Name string 35 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 36 61 } 37 62 38 63 func (s *InterdiffFile) String() string {
+4
rbac/rbac.go
··· 11 11 ) 12 12 13 13 const ( 14 + ThisServer = "thisserver" // resource identifier for local rbac enforcement 15 + ) 16 + 17 + const ( 14 18 Model = ` 15 19 [request_definition] 16 20 r = sub, dom, obj, act
+23 -6
spindle/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 9 11 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"` 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"` 16 33 } 17 34 18 35 type Pipelines struct {
+43 -20
spindle/engine/engine.go
··· 11 11 "sync" 12 12 "time" 13 13 14 + securejoin "github.com/cyphar/filepath-securejoin" 14 15 "github.com/docker/docker/api/types/container" 15 16 "github.com/docker/docker/api/types/image" 16 17 "github.com/docker/docker/api/types/mount" ··· 18 19 "github.com/docker/docker/api/types/volume" 19 20 "github.com/docker/docker/client" 20 21 "github.com/docker/docker/pkg/stdcopy" 22 + "golang.org/x/sync/errgroup" 21 23 "tangled.sh/tangled.sh/core/log" 22 24 "tangled.sh/tangled.sh/core/notifier" 23 25 "tangled.sh/tangled.sh/core/spindle/config" 24 26 "tangled.sh/tangled.sh/core/spindle/db" 25 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 29 ) 27 30 28 31 const ( ··· 37 40 db *db.DB 38 41 n *notifier.Notifier 39 42 cfg *config.Config 43 + vault secrets.Manager 40 44 41 45 cleanupMu sync.Mutex 42 46 cleanup map[string][]cleanupFunc 43 47 } 44 48 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 49 + func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 46 50 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 51 if err != nil { 48 52 return nil, err ··· 56 60 db: db, 57 61 n: n, 58 62 cfg: cfg, 63 + vault: vault, 59 64 } 60 65 61 66 e.cleanup = make(map[string][]cleanupFunc) ··· 66 71 func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 72 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 73 69 - wg := sync.WaitGroup{} 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) 70 91 for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 92 + eg.Go(func() error { 74 93 wid := models.WorkflowId{ 75 94 PipelineId: pipelineId, 76 95 Name: w.Name, ··· 90 109 91 110 reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 92 111 if err != nil { 93 - e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error()) 112 + e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error()) 94 113 95 114 err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 96 115 if err != nil { ··· 102 121 defer reader.Close() 103 122 io.Copy(os.Stdout, reader) 104 123 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 124 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 125 defer cancel() 114 126 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 127 + err = e.StartSteps(ctx, wid, w, allSecrets) 116 128 if err != nil { 117 129 if errors.Is(err, ErrTimedOut) { 118 130 dbErr := e.db.StatusTimeout(wid, e.n) ··· 135 147 } 136 148 137 149 return nil 138 - }() 150 + }) 139 151 } 140 152 141 - wg.Wait() 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 + } 142 158 } 143 159 144 160 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 186 202 // ONLY marks pipeline as failed if container's exit code is non-zero. 187 203 // All other errors are bubbled up. 188 204 // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 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 + } 190 210 191 - for stepIdx, step := range steps { 211 + for stepIdx, step := range w.Steps { 192 212 select { 193 213 case <-ctx.Done(): 194 214 return ctx.Err() 195 215 default: 196 216 } 197 217 198 - envs := ConstructEnvs(step.Environment) 218 + envs := append(EnvVars(nil), workflowEnvs...) 219 + for k, v := range step.Environment { 220 + envs.AddEnv(k, v) 221 + } 199 222 envs.AddEnv("HOME", workspaceDir) 200 223 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 224 202 225 hostConfig := hostConfig(wid) 203 226 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 227 + Image: w.Image, 205 228 Cmd: []string{"bash", "-c", step.Command}, 206 229 WorkingDir: workspaceDir, 207 230 Tty: false,
+1 -1
spindle/engine/envs_test.go
··· 34 34 if got == nil { 35 35 got = EnvVars{} 36 36 } 37 - assert.Equal(t, tt.want, got) 37 + assert.ElementsMatch(t, tt.want, got) 38 38 }) 39 39 } 40 40 }
+129 -2
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 8 9 "tangled.sh/tangled.sh/core/api/tangled" 9 10 "tangled.sh/tangled.sh/core/eventconsumer" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/rbac" 10 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" 11 18 "github.com/bluesky-social/jetstream/pkg/models" 19 + securejoin "github.com/cyphar/filepath-securejoin" 12 20 ) 13 21 14 22 type Ingester func(ctx context.Context, e *models.Event) error ··· 33 41 s.ingestMember(ctx, e) 34 42 case tangled.RepoNSID: 35 43 s.ingestRepo(ctx, e) 44 + case tangled.RepoCollaboratorNSID: 45 + s.ingestCollaborator(ctx, e) 36 46 } 37 47 38 48 return err ··· 72 82 return fmt.Errorf("failed to enforce permissions: %w", err) 73 83 } 74 84 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 85 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 76 86 l.Error("failed to add member", "error", err) 77 87 return fmt.Errorf("failed to add member: %w", err) 78 88 } ··· 90 100 return nil 91 101 } 92 102 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 103 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 94 104 var err error 105 + did := e.Did 106 + resolver := idresolver.DefaultResolver() 95 107 96 108 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 109 ··· 127 139 return fmt.Errorf("failed to add repo: %w", err) 128 140 } 129 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 + 130 162 // add this knot to the event consumer 131 163 src := eventconsumer.NewKnotSource(record.Knot) 132 164 s.ks.AddSource(context.Background(), src) ··· 136 168 } 137 169 return nil 138 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 8 ) 9 9 10 10 type Pipeline struct { 11 + RepoOwner string 12 + RepoName string 11 13 Workflows []Workflow 12 14 } 13 15 ··· 63 65 swf.Environment = workflowEnvToMap(twf.Environment) 64 66 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 67 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 68 setup := &setupSteps{} 69 69 70 70 setup.addStep(nixConfStep()) ··· 79 79 80 80 workflows = append(workflows, *swf) 81 81 } 82 - return &Pipeline{Workflows: workflows} 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 + } 83 89 } 84 90 85 91 func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { ··· 115 121 116 122 return path.Join(nixery, dependencies) 117 123 } 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 - }
+3
spindle/models/setup_steps.go
··· 102 102 continue 103 103 } 104 104 105 + if len(packages) == 0 { 106 + customPackages = append(customPackages, registry) 107 + } 105 108 // collect packages from custom registries 106 109 for _, pkg := range packages { 107 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 2 3 3 import ( 4 4 "context" 5 + _ "embed" 5 6 "encoding/json" 6 7 "fmt" 7 8 "log/slog" ··· 11 12 "tangled.sh/tangled.sh/core/api/tangled" 12 13 "tangled.sh/tangled.sh/core/eventconsumer" 13 14 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 + "tangled.sh/tangled.sh/core/idresolver" 14 16 "tangled.sh/tangled.sh/core/jetstream" 15 17 "tangled.sh/tangled.sh/core/log" 16 18 "tangled.sh/tangled.sh/core/notifier" ··· 20 22 "tangled.sh/tangled.sh/core/spindle/engine" 21 23 "tangled.sh/tangled.sh/core/spindle/models" 22 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 + "tangled.sh/tangled.sh/core/spindle/xrpc" 23 27 ) 24 28 29 + //go:embed motd 30 + var motd []byte 31 + 25 32 const ( 26 33 rbacDomain = "thisserver" 27 34 ) 28 35 29 36 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 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 39 48 } 40 49 41 50 func Run(ctx context.Context) error { ··· 59 68 60 69 n := notifier.New() 61 70 62 - eng, err := engine.New(ctx, cfg, d, &n) 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) 63 97 if err != nil { 64 98 return err 65 99 } ··· 69 103 collections := []string{ 70 104 tangled.SpindleMemberNSID, 71 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 72 107 } 73 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 109 if err != nil { ··· 76 111 } 77 112 jc.AddDid(cfg.Server.Owner) 78 113 114 + resolver := idresolver.DefaultResolver() 115 + 79 116 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, 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, 88 127 } 89 128 90 129 err = e.AddSpindle(rbacDomain) ··· 100 139 // starts a job queue runner in the background 101 140 jq.Start() 102 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 + } 103 147 104 148 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 149 if err != nil { ··· 144 188 mux := chi.NewRouter() 145 189 146 190 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`)) 191 + w.Write(motd) 171 192 }) 172 193 mux.HandleFunc("/events", s.Events) 173 194 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 174 195 w.Write([]byte(s.cfg.Server.Owner)) 175 196 }) 176 197 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 198 + 199 + mux.Mount("/xrpc", s.XrpcRouter()) 177 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() 178 217 } 179 218 180 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 5 "github.com/go-git/go-git/v5/plumbing/object" 6 6 ) 7 7 8 + type DiffOpts struct { 9 + Split bool `json:"split"` 10 + } 11 + 8 12 type TextFragment struct { 9 13 Header string `json:"comment"` 10 14 Lines []gitdiff.Line `json:"lines"` ··· 77 81 78 82 return files 79 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 + }