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

Compare changes

Choose any two refs to compare.

Changed files
+17529 -6375
.air
.tangled
.zed
api
appview
cache
session
config
db
dns
idresolver
issues
knots
middleware
notify
oauth
pages
markup
templates
pipelines
posthog
pulls
repo
reporesolver
settings
signup
spindles
state
strings
xrpcclient
avatar
src
cmd
genjwks
punchcardPopulate
docs
eventconsumer
guard
hook
idresolver
jetstream
knotserver
lexicons
log
nix
patchutil
rbac
spindle
types
workflow
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+4
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+7 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 - - event: ["push"] 2 + - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 + 5 + engine: nixery 4 6 5 7 dependencies: 6 8 nixpkgs: ··· 22 24 - name: build knot 23 25 command: | 24 26 go build -o knot.out ./cmd/knot 27 + 28 + - name: build spindle 29 + command: | 30 + go build -o spindle.out ./cmd/spindle
+4 -12
.tangled/workflows/fmt.yml
··· 1 1 when: 2 - - event: ["push"] 2 + - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 12 - command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 8 + - name: "Check formatting" 16 9 command: | 17 - gofmt -l . 18 - 10 + nix run .#fmt -- --ci
+4 -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 + 5 + engine: nixery 4 6 5 7 dependencies: 6 8 nixpkgs:
-16
.zed/settings.json
··· 1 - // Folder-specific settings 2 - // 3 - // For a full list of overridable settings, and general information on folder-specific settings, 4 - // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 - { 6 - "languages": { 7 - "HTML": { 8 - "prettier": { 9 - "format_on_save": false, 10 - "allowed": true, 11 - "parser": "go-template", 12 - "plugins": ["prettier-plugin-go-template"] 13 - } 14 - } 15 - } 16 - }
+1027 -724
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: ··· 1437 1679 1438 1680 return nil 1439 1681 } 1682 + func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error { 1683 + if t == nil { 1684 + _, err := w.Write(cbg.CborNull) 1685 + return err 1686 + } 1687 + 1688 + cw := cbg.NewCborWriter(w) 1689 + fieldCount := 1 1690 + 1691 + if t.Inputs == nil { 1692 + fieldCount-- 1693 + } 1694 + 1695 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1696 + return err 1697 + } 1698 + 1699 + // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1700 + if t.Inputs != nil { 1701 + 1702 + if len("inputs") > 1000000 { 1703 + return xerrors.Errorf("Value in field \"inputs\" was too long") 1704 + } 1705 + 1706 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { 1707 + return err 1708 + } 1709 + if _, err := cw.WriteString(string("inputs")); err != nil { 1710 + return err 1711 + } 1712 + 1713 + if len(t.Inputs) > 8192 { 1714 + return xerrors.Errorf("Slice value in field t.Inputs was too long") 1715 + } 1716 + 1717 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1718 + return err 1719 + } 1720 + for _, v := range t.Inputs { 1721 + if err := v.MarshalCBOR(cw); err != nil { 1722 + return err 1723 + } 1724 + 1725 + } 1726 + } 1727 + return nil 1728 + } 1729 + 1730 + func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1731 + *t = GitRefUpdate_Meta_LangBreakdown{} 1732 + 1733 + cr := cbg.NewCborReader(r) 1734 + 1735 + maj, extra, err := cr.ReadHeader() 1736 + if err != nil { 1737 + return err 1738 + } 1739 + defer func() { 1740 + if err == io.EOF { 1741 + err = io.ErrUnexpectedEOF 1742 + } 1743 + }() 1744 + 1745 + if maj != cbg.MajMap { 1746 + return fmt.Errorf("cbor input should be of type map") 1747 + } 1748 + 1749 + if extra > cbg.MaxLength { 1750 + return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra) 1751 + } 1752 + 1753 + n := extra 1754 + 1755 + nameBuf := make([]byte, 6) 1756 + for i := uint64(0); i < n; i++ { 1757 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1758 + if err != nil { 1759 + return err 1760 + } 1761 + 1762 + if !ok { 1763 + // Field doesn't exist on this type, so ignore it 1764 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1765 + return err 1766 + } 1767 + continue 1768 + } 1769 + 1770 + switch string(nameBuf[:nameLen]) { 1771 + // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1772 + case "inputs": 1773 + 1774 + maj, extra, err = cr.ReadHeader() 1775 + if err != nil { 1776 + return err 1777 + } 1778 + 1779 + if extra > 8192 { 1780 + return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1781 + } 1782 + 1783 + if maj != cbg.MajArray { 1784 + return fmt.Errorf("expected cbor array") 1785 + } 1786 + 1787 + if extra > 0 { 1788 + t.Inputs = make([]*GitRefUpdate_Pair, extra) 1789 + } 1790 + 1791 + for i := 0; i < int(extra); i++ { 1792 + { 1793 + var maj byte 1794 + var extra uint64 1795 + var err error 1796 + _ = maj 1797 + _ = extra 1798 + _ = err 1799 + 1800 + { 1801 + 1802 + b, err := cr.ReadByte() 1803 + if err != nil { 1804 + return err 1805 + } 1806 + if b != cbg.CborNull[0] { 1807 + if err := cr.UnreadByte(); err != nil { 1808 + return err 1809 + } 1810 + t.Inputs[i] = new(GitRefUpdate_Pair) 1811 + if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1812 + return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1813 + } 1814 + } 1815 + 1816 + } 1817 + 1818 + } 1819 + } 1820 + 1821 + default: 1822 + // Field doesn't exist on this type, so ignore it 1823 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1824 + return err 1825 + } 1826 + } 1827 + } 1828 + 1829 + return nil 1830 + } 1831 + func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error { 1832 + if t == nil { 1833 + _, err := w.Write(cbg.CborNull) 1834 + return err 1835 + } 1836 + 1837 + cw := cbg.NewCborWriter(w) 1838 + 1839 + if _, err := cw.Write([]byte{162}); err != nil { 1840 + return err 1841 + } 1842 + 1843 + // t.Lang (string) (string) 1844 + if len("lang") > 1000000 { 1845 + return xerrors.Errorf("Value in field \"lang\" was too long") 1846 + } 1847 + 1848 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil { 1849 + return err 1850 + } 1851 + if _, err := cw.WriteString(string("lang")); err != nil { 1852 + return err 1853 + } 1854 + 1855 + if len(t.Lang) > 1000000 { 1856 + return xerrors.Errorf("Value in field t.Lang was too long") 1857 + } 1858 + 1859 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil { 1860 + return err 1861 + } 1862 + if _, err := cw.WriteString(string(t.Lang)); err != nil { 1863 + return err 1864 + } 1865 + 1866 + // t.Size (int64) (int64) 1867 + if len("size") > 1000000 { 1868 + return xerrors.Errorf("Value in field \"size\" was too long") 1869 + } 1870 + 1871 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1872 + return err 1873 + } 1874 + if _, err := cw.WriteString(string("size")); err != nil { 1875 + return err 1876 + } 1877 + 1878 + if t.Size >= 0 { 1879 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1880 + return err 1881 + } 1882 + } else { 1883 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1884 + return err 1885 + } 1886 + } 1887 + 1888 + return nil 1889 + } 1890 + 1891 + func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) { 1892 + *t = GitRefUpdate_Pair{} 1893 + 1894 + cr := cbg.NewCborReader(r) 1895 + 1896 + maj, extra, err := cr.ReadHeader() 1897 + if err != nil { 1898 + return err 1899 + } 1900 + defer func() { 1901 + if err == io.EOF { 1902 + err = io.ErrUnexpectedEOF 1903 + } 1904 + }() 1905 + 1906 + if maj != cbg.MajMap { 1907 + return fmt.Errorf("cbor input should be of type map") 1908 + } 1909 + 1910 + if extra > cbg.MaxLength { 1911 + return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra) 1912 + } 1913 + 1914 + n := extra 1915 + 1916 + nameBuf := make([]byte, 4) 1917 + for i := uint64(0); i < n; i++ { 1918 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1919 + if err != nil { 1920 + return err 1921 + } 1922 + 1923 + if !ok { 1924 + // Field doesn't exist on this type, so ignore it 1925 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1926 + return err 1927 + } 1928 + continue 1929 + } 1930 + 1931 + switch string(nameBuf[:nameLen]) { 1932 + // t.Lang (string) (string) 1933 + case "lang": 1934 + 1935 + { 1936 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1937 + if err != nil { 1938 + return err 1939 + } 1940 + 1941 + t.Lang = string(sval) 1942 + } 1943 + // t.Size (int64) (int64) 1944 + case "size": 1945 + { 1946 + maj, extra, err := cr.ReadHeader() 1947 + if err != nil { 1948 + return err 1949 + } 1950 + var extraI int64 1951 + switch maj { 1952 + case cbg.MajUnsignedInt: 1953 + extraI = int64(extra) 1954 + if extraI < 0 { 1955 + return fmt.Errorf("int64 positive overflow") 1956 + } 1957 + case cbg.MajNegativeInt: 1958 + extraI = int64(extra) 1959 + if extraI < 0 { 1960 + return fmt.Errorf("int64 negative overflow") 1961 + } 1962 + extraI = -1 - extraI 1963 + default: 1964 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1965 + } 1966 + 1967 + t.Size = int64(extraI) 1968 + } 1969 + 1970 + default: 1971 + // Field doesn't exist on this type, so ignore it 1972 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1973 + return err 1974 + } 1975 + } 1976 + } 1977 + 1978 + return nil 1979 + } 1440 1980 func (t *GraphFollow) MarshalCBOR(w io.Writer) error { 1441 1981 if t == nil { 1442 1982 _, err := w.Write(cbg.CborNull) ··· 2188 2728 2189 2729 return nil 2190 2730 } 2191 - func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error { 2192 - if t == nil { 2193 - _, err := w.Write(cbg.CborNull) 2194 - return err 2195 - } 2196 - 2197 - cw := cbg.NewCborWriter(w) 2198 - 2199 - if _, err := cw.Write([]byte{162}); err != nil { 2200 - return err 2201 - } 2202 - 2203 - // t.Packages ([]string) (slice) 2204 - if len("packages") > 1000000 { 2205 - return xerrors.Errorf("Value in field \"packages\" was too long") 2206 - } 2207 - 2208 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil { 2209 - return err 2210 - } 2211 - if _, err := cw.WriteString(string("packages")); err != nil { 2212 - return err 2213 - } 2214 - 2215 - if len(t.Packages) > 8192 { 2216 - return xerrors.Errorf("Slice value in field t.Packages was too long") 2217 - } 2218 - 2219 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil { 2220 - return err 2221 - } 2222 - for _, v := range t.Packages { 2223 - if len(v) > 1000000 { 2224 - return xerrors.Errorf("Value in field v was too long") 2225 - } 2226 - 2227 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2228 - return err 2229 - } 2230 - if _, err := cw.WriteString(string(v)); err != nil { 2231 - return err 2232 - } 2233 - 2234 - } 2235 - 2236 - // t.Registry (string) (string) 2237 - if len("registry") > 1000000 { 2238 - return xerrors.Errorf("Value in field \"registry\" was too long") 2239 - } 2240 - 2241 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil { 2242 - return err 2243 - } 2244 - if _, err := cw.WriteString(string("registry")); err != nil { 2245 - return err 2246 - } 2247 - 2248 - if len(t.Registry) > 1000000 { 2249 - return xerrors.Errorf("Value in field t.Registry was too long") 2250 - } 2251 - 2252 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil { 2253 - return err 2254 - } 2255 - if _, err := cw.WriteString(string(t.Registry)); err != nil { 2256 - return err 2257 - } 2258 - return nil 2259 - } 2260 - 2261 - func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) { 2262 - *t = Pipeline_Dependency{} 2263 - 2264 - cr := cbg.NewCborReader(r) 2265 - 2266 - maj, extra, err := cr.ReadHeader() 2267 - if err != nil { 2268 - return err 2269 - } 2270 - defer func() { 2271 - if err == io.EOF { 2272 - err = io.ErrUnexpectedEOF 2273 - } 2274 - }() 2275 - 2276 - if maj != cbg.MajMap { 2277 - return fmt.Errorf("cbor input should be of type map") 2278 - } 2279 - 2280 - if extra > cbg.MaxLength { 2281 - return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra) 2282 - } 2283 - 2284 - n := extra 2285 - 2286 - nameBuf := make([]byte, 8) 2287 - for i := uint64(0); i < n; i++ { 2288 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2289 - if err != nil { 2290 - return err 2291 - } 2292 - 2293 - if !ok { 2294 - // Field doesn't exist on this type, so ignore it 2295 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2296 - return err 2297 - } 2298 - continue 2299 - } 2300 - 2301 - switch string(nameBuf[:nameLen]) { 2302 - // t.Packages ([]string) (slice) 2303 - case "packages": 2304 - 2305 - maj, extra, err = cr.ReadHeader() 2306 - if err != nil { 2307 - return err 2308 - } 2309 - 2310 - if extra > 8192 { 2311 - return fmt.Errorf("t.Packages: array too large (%d)", extra) 2312 - } 2313 - 2314 - if maj != cbg.MajArray { 2315 - return fmt.Errorf("expected cbor array") 2316 - } 2317 - 2318 - if extra > 0 { 2319 - t.Packages = make([]string, extra) 2320 - } 2321 - 2322 - for i := 0; i < int(extra); i++ { 2323 - { 2324 - var maj byte 2325 - var extra uint64 2326 - var err error 2327 - _ = maj 2328 - _ = extra 2329 - _ = err 2330 - 2331 - { 2332 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2333 - if err != nil { 2334 - return err 2335 - } 2336 - 2337 - t.Packages[i] = string(sval) 2338 - } 2339 - 2340 - } 2341 - } 2342 - // t.Registry (string) (string) 2343 - case "registry": 2344 - 2345 - { 2346 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2347 - if err != nil { 2348 - return err 2349 - } 2350 - 2351 - t.Registry = string(sval) 2352 - } 2353 - 2354 - default: 2355 - // Field doesn't exist on this type, so ignore it 2356 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2357 - return err 2358 - } 2359 - } 2360 - } 2361 - 2362 - return nil 2363 - } 2364 2731 func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error { 2365 2732 if t == nil { 2366 2733 _, err := w.Write(cbg.CborNull) ··· 3376 3743 3377 3744 return nil 3378 3745 } 3379 - func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error { 3380 - if t == nil { 3381 - _, err := w.Write(cbg.CborNull) 3382 - return err 3383 - } 3384 - 3385 - cw := cbg.NewCborWriter(w) 3386 - fieldCount := 3 3387 - 3388 - if t.Environment == nil { 3389 - fieldCount-- 3390 - } 3391 - 3392 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3393 - return err 3394 - } 3395 - 3396 - // t.Name (string) (string) 3397 - if len("name") > 1000000 { 3398 - return xerrors.Errorf("Value in field \"name\" was too long") 3399 - } 3400 - 3401 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 3402 - return err 3403 - } 3404 - if _, err := cw.WriteString(string("name")); err != nil { 3405 - return err 3406 - } 3407 - 3408 - if len(t.Name) > 1000000 { 3409 - return xerrors.Errorf("Value in field t.Name was too long") 3410 - } 3411 - 3412 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 3413 - return err 3414 - } 3415 - if _, err := cw.WriteString(string(t.Name)); err != nil { 3416 - return err 3417 - } 3418 - 3419 - // t.Command (string) (string) 3420 - if len("command") > 1000000 { 3421 - return xerrors.Errorf("Value in field \"command\" was too long") 3422 - } 3423 - 3424 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil { 3425 - return err 3426 - } 3427 - if _, err := cw.WriteString(string("command")); err != nil { 3428 - return err 3429 - } 3430 - 3431 - if len(t.Command) > 1000000 { 3432 - return xerrors.Errorf("Value in field t.Command was too long") 3433 - } 3434 - 3435 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil { 3436 - return err 3437 - } 3438 - if _, err := cw.WriteString(string(t.Command)); err != nil { 3439 - return err 3440 - } 3441 - 3442 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 3443 - if t.Environment != nil { 3444 - 3445 - if len("environment") > 1000000 { 3446 - return xerrors.Errorf("Value in field \"environment\" was too long") 3447 - } 3448 - 3449 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 3450 - return err 3451 - } 3452 - if _, err := cw.WriteString(string("environment")); err != nil { 3453 - return err 3454 - } 3455 - 3456 - if len(t.Environment) > 8192 { 3457 - return xerrors.Errorf("Slice value in field t.Environment was too long") 3458 - } 3459 - 3460 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 3461 - return err 3462 - } 3463 - for _, v := range t.Environment { 3464 - if err := v.MarshalCBOR(cw); err != nil { 3465 - return err 3466 - } 3467 - 3468 - } 3469 - } 3470 - return nil 3471 - } 3472 - 3473 - func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) { 3474 - *t = Pipeline_Step{} 3475 - 3476 - cr := cbg.NewCborReader(r) 3477 - 3478 - maj, extra, err := cr.ReadHeader() 3479 - if err != nil { 3480 - return err 3481 - } 3482 - defer func() { 3483 - if err == io.EOF { 3484 - err = io.ErrUnexpectedEOF 3485 - } 3486 - }() 3487 - 3488 - if maj != cbg.MajMap { 3489 - return fmt.Errorf("cbor input should be of type map") 3490 - } 3491 - 3492 - if extra > cbg.MaxLength { 3493 - return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra) 3494 - } 3495 - 3496 - n := extra 3497 - 3498 - nameBuf := make([]byte, 11) 3499 - for i := uint64(0); i < n; i++ { 3500 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3501 - if err != nil { 3502 - return err 3503 - } 3504 - 3505 - if !ok { 3506 - // Field doesn't exist on this type, so ignore it 3507 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3508 - return err 3509 - } 3510 - continue 3511 - } 3512 - 3513 - switch string(nameBuf[:nameLen]) { 3514 - // t.Name (string) (string) 3515 - case "name": 3516 - 3517 - { 3518 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 3519 - if err != nil { 3520 - return err 3521 - } 3522 - 3523 - t.Name = string(sval) 3524 - } 3525 - // t.Command (string) (string) 3526 - case "command": 3527 - 3528 - { 3529 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 3530 - if err != nil { 3531 - return err 3532 - } 3533 - 3534 - t.Command = string(sval) 3535 - } 3536 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 3537 - case "environment": 3538 - 3539 - maj, extra, err = cr.ReadHeader() 3540 - if err != nil { 3541 - return err 3542 - } 3543 - 3544 - if extra > 8192 { 3545 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 3546 - } 3547 - 3548 - if maj != cbg.MajArray { 3549 - return fmt.Errorf("expected cbor array") 3550 - } 3551 - 3552 - if extra > 0 { 3553 - t.Environment = make([]*Pipeline_Pair, extra) 3554 - } 3555 - 3556 - for i := 0; i < int(extra); i++ { 3557 - { 3558 - var maj byte 3559 - var extra uint64 3560 - var err error 3561 - _ = maj 3562 - _ = extra 3563 - _ = err 3564 - 3565 - { 3566 - 3567 - b, err := cr.ReadByte() 3568 - if err != nil { 3569 - return err 3570 - } 3571 - if b != cbg.CborNull[0] { 3572 - if err := cr.UnreadByte(); err != nil { 3573 - return err 3574 - } 3575 - t.Environment[i] = new(Pipeline_Pair) 3576 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 3577 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 3578 - } 3579 - } 3580 - 3581 - } 3582 - 3583 - } 3584 - } 3585 - 3586 - default: 3587 - // Field doesn't exist on this type, so ignore it 3588 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3589 - return err 3590 - } 3591 - } 3592 - } 3593 - 3594 - return nil 3595 - } 3596 3746 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 3597 3747 if t == nil { 3598 3748 _, err := w.Write(cbg.CborNull) ··· 4069 4219 4070 4220 cw := cbg.NewCborWriter(w) 4071 4221 4072 - if _, err := cw.Write([]byte{165}); err != nil { 4222 + if _, err := cw.Write([]byte{164}); err != nil { 4223 + return err 4224 + } 4225 + 4226 + // t.Raw (string) (string) 4227 + if len("raw") > 1000000 { 4228 + return xerrors.Errorf("Value in field \"raw\" was too long") 4229 + } 4230 + 4231 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4232 + return err 4233 + } 4234 + if _, err := cw.WriteString(string("raw")); err != nil { 4235 + return err 4236 + } 4237 + 4238 + if len(t.Raw) > 1000000 { 4239 + return xerrors.Errorf("Value in field t.Raw was too long") 4240 + } 4241 + 4242 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4243 + return err 4244 + } 4245 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4073 4246 return err 4074 4247 } 4075 4248 ··· 4112 4285 return err 4113 4286 } 4114 4287 4115 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4116 - if len("steps") > 1000000 { 4117 - return xerrors.Errorf("Value in field \"steps\" was too long") 4288 + // t.Engine (string) (string) 4289 + if len("engine") > 1000000 { 4290 + return xerrors.Errorf("Value in field \"engine\" was too long") 4118 4291 } 4119 4292 4120 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4121 - return err 4122 - } 4123 - if _, err := cw.WriteString(string("steps")); err != nil { 4293 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4124 4294 return err 4125 4295 } 4126 - 4127 - if len(t.Steps) > 8192 { 4128 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4129 - } 4130 - 4131 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4132 - return err 4133 - } 4134 - for _, v := range t.Steps { 4135 - if err := v.MarshalCBOR(cw); err != nil { 4136 - return err 4137 - } 4138 - 4139 - } 4140 - 4141 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4142 - if len("environment") > 1000000 { 4143 - return xerrors.Errorf("Value in field \"environment\" was too long") 4144 - } 4145 - 4146 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 4147 - return err 4148 - } 4149 - if _, err := cw.WriteString(string("environment")); err != nil { 4150 - return err 4151 - } 4152 - 4153 - if len(t.Environment) > 8192 { 4154 - return xerrors.Errorf("Slice value in field t.Environment was too long") 4155 - } 4156 - 4157 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4296 + if _, err := cw.WriteString(string("engine")); err != nil { 4158 4297 return err 4159 4298 } 4160 - for _, v := range t.Environment { 4161 - if err := v.MarshalCBOR(cw); err != nil { 4162 - return err 4163 - } 4164 4299 4300 + if len(t.Engine) > 1000000 { 4301 + return xerrors.Errorf("Value in field t.Engine was too long") 4165 4302 } 4166 4303 4167 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4168 - if len("dependencies") > 1000000 { 4169 - return xerrors.Errorf("Value in field \"dependencies\" was too long") 4170 - } 4171 - 4172 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil { 4304 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4173 4305 return err 4174 4306 } 4175 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4307 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4176 4308 return err 4177 - } 4178 - 4179 - if len(t.Dependencies) > 8192 { 4180 - return xerrors.Errorf("Slice value in field t.Dependencies was too long") 4181 - } 4182 - 4183 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil { 4184 - return err 4185 - } 4186 - for _, v := range t.Dependencies { 4187 - if err := v.MarshalCBOR(cw); err != nil { 4188 - return err 4189 - } 4190 - 4191 4309 } 4192 4310 return nil 4193 4311 } ··· 4217 4335 4218 4336 n := extra 4219 4337 4220 - nameBuf := make([]byte, 12) 4338 + nameBuf := make([]byte, 6) 4221 4339 for i := uint64(0); i < n; i++ { 4222 4340 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4223 4341 if err != nil { ··· 4233 4351 } 4234 4352 4235 4353 switch string(nameBuf[:nameLen]) { 4236 - // t.Name (string) (string) 4354 + // t.Raw (string) (string) 4355 + case "raw": 4356 + 4357 + { 4358 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4359 + if err != nil { 4360 + return err 4361 + } 4362 + 4363 + t.Raw = string(sval) 4364 + } 4365 + // t.Name (string) (string) 4237 4366 case "name": 4238 4367 4239 4368 { ··· 4264 4393 } 4265 4394 4266 4395 } 4267 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4268 - case "steps": 4396 + // t.Engine (string) (string) 4397 + case "engine": 4269 4398 4270 - maj, extra, err = cr.ReadHeader() 4271 - if err != nil { 4272 - return err 4273 - } 4274 - 4275 - if extra > 8192 { 4276 - return fmt.Errorf("t.Steps: array too large (%d)", extra) 4277 - } 4278 - 4279 - if maj != cbg.MajArray { 4280 - return fmt.Errorf("expected cbor array") 4281 - } 4282 - 4283 - if extra > 0 { 4284 - t.Steps = make([]*Pipeline_Step, extra) 4285 - } 4286 - 4287 - for i := 0; i < int(extra); i++ { 4288 - { 4289 - var maj byte 4290 - var extra uint64 4291 - var err error 4292 - _ = maj 4293 - _ = extra 4294 - _ = err 4295 - 4296 - { 4297 - 4298 - b, err := cr.ReadByte() 4299 - if err != nil { 4300 - return err 4301 - } 4302 - if b != cbg.CborNull[0] { 4303 - if err := cr.UnreadByte(); err != nil { 4304 - return err 4305 - } 4306 - t.Steps[i] = new(Pipeline_Step) 4307 - if err := t.Steps[i].UnmarshalCBOR(cr); err != nil { 4308 - return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err) 4309 - } 4310 - } 4311 - 4312 - } 4313 - 4399 + { 4400 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4401 + if err != nil { 4402 + return err 4314 4403 } 4315 - } 4316 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4317 - case "environment": 4318 4404 4319 - maj, extra, err = cr.ReadHeader() 4320 - if err != nil { 4321 - return err 4322 - } 4323 - 4324 - if extra > 8192 { 4325 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4326 - } 4327 - 4328 - if maj != cbg.MajArray { 4329 - return fmt.Errorf("expected cbor array") 4330 - } 4331 - 4332 - if extra > 0 { 4333 - t.Environment = make([]*Pipeline_Pair, extra) 4334 - } 4335 - 4336 - for i := 0; i < int(extra); i++ { 4337 - { 4338 - var maj byte 4339 - var extra uint64 4340 - var err error 4341 - _ = maj 4342 - _ = extra 4343 - _ = err 4344 - 4345 - { 4346 - 4347 - b, err := cr.ReadByte() 4348 - if err != nil { 4349 - return err 4350 - } 4351 - if b != cbg.CborNull[0] { 4352 - if err := cr.UnreadByte(); err != nil { 4353 - return err 4354 - } 4355 - t.Environment[i] = new(Pipeline_Pair) 4356 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4357 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4358 - } 4359 - } 4360 - 4361 - } 4362 - 4363 - } 4364 - } 4365 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4366 - case "dependencies": 4367 - 4368 - maj, extra, err = cr.ReadHeader() 4369 - if err != nil { 4370 - return err 4371 - } 4372 - 4373 - if extra > 8192 { 4374 - return fmt.Errorf("t.Dependencies: array too large (%d)", extra) 4375 - } 4376 - 4377 - if maj != cbg.MajArray { 4378 - return fmt.Errorf("expected cbor array") 4379 - } 4380 - 4381 - if extra > 0 { 4382 - t.Dependencies = make([]*Pipeline_Dependency, extra) 4383 - } 4384 - 4385 - for i := 0; i < int(extra); i++ { 4386 - { 4387 - var maj byte 4388 - var extra uint64 4389 - var err error 4390 - _ = maj 4391 - _ = extra 4392 - _ = err 4393 - 4394 - { 4395 - 4396 - b, err := cr.ReadByte() 4397 - if err != nil { 4398 - return err 4399 - } 4400 - if b != cbg.CborNull[0] { 4401 - if err := cr.UnreadByte(); err != nil { 4402 - return err 4403 - } 4404 - t.Dependencies[i] = new(Pipeline_Dependency) 4405 - if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil { 4406 - return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err) 4407 - } 4408 - } 4409 - 4410 - } 4411 - 4412 - } 4405 + t.Engine = string(sval) 4413 4406 } 4414 4407 4415 4408 default: ··· 5314 5307 5315 5308 return nil 5316 5309 } 5310 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5311 + if t == nil { 5312 + _, err := w.Write(cbg.CborNull) 5313 + return err 5314 + } 5315 + 5316 + cw := cbg.NewCborWriter(w) 5317 + 5318 + if _, err := cw.Write([]byte{164}); err != nil { 5319 + return err 5320 + } 5321 + 5322 + // t.Repo (string) (string) 5323 + if len("repo") > 1000000 { 5324 + return xerrors.Errorf("Value in field \"repo\" was too long") 5325 + } 5326 + 5327 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5328 + return err 5329 + } 5330 + if _, err := cw.WriteString(string("repo")); err != nil { 5331 + return err 5332 + } 5333 + 5334 + if len(t.Repo) > 1000000 { 5335 + return xerrors.Errorf("Value in field t.Repo was too long") 5336 + } 5337 + 5338 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5339 + return err 5340 + } 5341 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5342 + return err 5343 + } 5344 + 5345 + // t.LexiconTypeID (string) (string) 5346 + if len("$type") > 1000000 { 5347 + return xerrors.Errorf("Value in field \"$type\" was too long") 5348 + } 5349 + 5350 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5351 + return err 5352 + } 5353 + if _, err := cw.WriteString(string("$type")); err != nil { 5354 + return err 5355 + } 5356 + 5357 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5358 + return err 5359 + } 5360 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5361 + return err 5362 + } 5363 + 5364 + // t.Subject (string) (string) 5365 + if len("subject") > 1000000 { 5366 + return xerrors.Errorf("Value in field \"subject\" was too long") 5367 + } 5368 + 5369 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5370 + return err 5371 + } 5372 + if _, err := cw.WriteString(string("subject")); err != nil { 5373 + return err 5374 + } 5375 + 5376 + if len(t.Subject) > 1000000 { 5377 + return xerrors.Errorf("Value in field t.Subject was too long") 5378 + } 5379 + 5380 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5381 + return err 5382 + } 5383 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5384 + return err 5385 + } 5386 + 5387 + // t.CreatedAt (string) (string) 5388 + if len("createdAt") > 1000000 { 5389 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5390 + } 5391 + 5392 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5393 + return err 5394 + } 5395 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5396 + return err 5397 + } 5398 + 5399 + if len(t.CreatedAt) > 1000000 { 5400 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5401 + } 5402 + 5403 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5404 + return err 5405 + } 5406 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5407 + return err 5408 + } 5409 + return nil 5410 + } 5411 + 5412 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5413 + *t = RepoCollaborator{} 5414 + 5415 + cr := cbg.NewCborReader(r) 5416 + 5417 + maj, extra, err := cr.ReadHeader() 5418 + if err != nil { 5419 + return err 5420 + } 5421 + defer func() { 5422 + if err == io.EOF { 5423 + err = io.ErrUnexpectedEOF 5424 + } 5425 + }() 5426 + 5427 + if maj != cbg.MajMap { 5428 + return fmt.Errorf("cbor input should be of type map") 5429 + } 5430 + 5431 + if extra > cbg.MaxLength { 5432 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5433 + } 5434 + 5435 + n := extra 5436 + 5437 + nameBuf := make([]byte, 9) 5438 + for i := uint64(0); i < n; i++ { 5439 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5440 + if err != nil { 5441 + return err 5442 + } 5443 + 5444 + if !ok { 5445 + // Field doesn't exist on this type, so ignore it 5446 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5447 + return err 5448 + } 5449 + continue 5450 + } 5451 + 5452 + switch string(nameBuf[:nameLen]) { 5453 + // t.Repo (string) (string) 5454 + case "repo": 5455 + 5456 + { 5457 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5458 + if err != nil { 5459 + return err 5460 + } 5461 + 5462 + t.Repo = string(sval) 5463 + } 5464 + // t.LexiconTypeID (string) (string) 5465 + case "$type": 5466 + 5467 + { 5468 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5469 + if err != nil { 5470 + return err 5471 + } 5472 + 5473 + t.LexiconTypeID = string(sval) 5474 + } 5475 + // t.Subject (string) (string) 5476 + case "subject": 5477 + 5478 + { 5479 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5480 + if err != nil { 5481 + return err 5482 + } 5483 + 5484 + t.Subject = string(sval) 5485 + } 5486 + // t.CreatedAt (string) (string) 5487 + case "createdAt": 5488 + 5489 + { 5490 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5491 + if err != nil { 5492 + return err 5493 + } 5494 + 5495 + t.CreatedAt = string(sval) 5496 + } 5497 + 5498 + default: 5499 + // Field doesn't exist on this type, so ignore it 5500 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5501 + return err 5502 + } 5503 + } 5504 + } 5505 + 5506 + return nil 5507 + } 5317 5508 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5318 5509 if t == nil { 5319 5510 _, err := w.Write(cbg.CborNull) ··· 5321 5512 } 5322 5513 5323 5514 cw := cbg.NewCborWriter(w) 5324 - fieldCount := 7 5515 + fieldCount := 6 5325 5516 5326 5517 if t.Body == nil { 5327 5518 fieldCount-- ··· 5451 5642 return err 5452 5643 } 5453 5644 5454 - // t.IssueId (int64) (int64) 5455 - if len("issueId") > 1000000 { 5456 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5457 - } 5458 - 5459 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 5460 - return err 5461 - } 5462 - if _, err := cw.WriteString(string("issueId")); err != nil { 5463 - return err 5464 - } 5465 - 5466 - if t.IssueId >= 0 { 5467 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 5468 - return err 5469 - } 5470 - } else { 5471 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 5472 - return err 5473 - } 5474 - } 5475 - 5476 5645 // t.CreatedAt (string) (string) 5477 5646 if len("createdAt") > 1000000 { 5478 5647 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5604 5773 5605 5774 t.Title = string(sval) 5606 5775 } 5607 - // t.IssueId (int64) (int64) 5608 - case "issueId": 5609 - { 5610 - maj, extra, err := cr.ReadHeader() 5611 - if err != nil { 5612 - return err 5613 - } 5614 - var extraI int64 5615 - switch maj { 5616 - case cbg.MajUnsignedInt: 5617 - extraI = int64(extra) 5618 - if extraI < 0 { 5619 - return fmt.Errorf("int64 positive overflow") 5620 - } 5621 - case cbg.MajNegativeInt: 5622 - extraI = int64(extra) 5623 - if extraI < 0 { 5624 - return fmt.Errorf("int64 negative overflow") 5625 - } 5626 - extraI = -1 - extraI 5627 - default: 5628 - return fmt.Errorf("wrong type for int64 field: %d", maj) 5629 - } 5630 - 5631 - t.IssueId = int64(extraI) 5632 - } 5633 5776 // t.CreatedAt (string) (string) 5634 5777 case "createdAt": 5635 5778 ··· 5659 5802 } 5660 5803 5661 5804 cw := cbg.NewCborWriter(w) 5662 - fieldCount := 7 5663 - 5664 - if t.CommentId == nil { 5665 - fieldCount-- 5666 - } 5805 + fieldCount := 6 5667 5806 5668 5807 if t.Owner == nil { 5669 5808 fieldCount-- ··· 5806 5945 } 5807 5946 } 5808 5947 5809 - // t.CommentId (int64) (int64) 5810 - if t.CommentId != nil { 5811 - 5812 - if len("commentId") > 1000000 { 5813 - return xerrors.Errorf("Value in field \"commentId\" was too long") 5814 - } 5815 - 5816 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 5817 - return err 5818 - } 5819 - if _, err := cw.WriteString(string("commentId")); err != nil { 5820 - return err 5821 - } 5822 - 5823 - if t.CommentId == nil { 5824 - if _, err := cw.Write(cbg.CborNull); err != nil { 5825 - return err 5826 - } 5827 - } else { 5828 - if *t.CommentId >= 0 { 5829 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 5830 - return err 5831 - } 5832 - } else { 5833 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 5834 - return err 5835 - } 5836 - } 5837 - } 5838 - 5839 - } 5840 - 5841 5948 // t.CreatedAt (string) (string) 5842 5949 if len("createdAt") > 1000000 { 5843 5950 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5977 6084 } 5978 6085 5979 6086 t.Owner = (*string)(&sval) 5980 - } 5981 - } 5982 - // t.CommentId (int64) (int64) 5983 - case "commentId": 5984 - { 5985 - 5986 - b, err := cr.ReadByte() 5987 - if err != nil { 5988 - return err 5989 - } 5990 - if b != cbg.CborNull[0] { 5991 - if err := cr.UnreadByte(); err != nil { 5992 - return err 5993 - } 5994 - maj, extra, err := cr.ReadHeader() 5995 - if err != nil { 5996 - return err 5997 - } 5998 - var extraI int64 5999 - switch maj { 6000 - case cbg.MajUnsignedInt: 6001 - extraI = int64(extra) 6002 - if extraI < 0 { 6003 - return fmt.Errorf("int64 positive overflow") 6004 - } 6005 - case cbg.MajNegativeInt: 6006 - extraI = int64(extra) 6007 - if extraI < 0 { 6008 - return fmt.Errorf("int64 negative overflow") 6009 - } 6010 - extraI = -1 - extraI 6011 - default: 6012 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6013 - } 6014 - 6015 - t.CommentId = (*int64)(&extraI) 6016 6087 } 6017 6088 } 6018 6089 // t.CreatedAt (string) (string) ··· 7685 7756 7686 7757 return nil 7687 7758 } 7759 + func (t *String) MarshalCBOR(w io.Writer) error { 7760 + if t == nil { 7761 + _, err := w.Write(cbg.CborNull) 7762 + return err 7763 + } 7764 + 7765 + cw := cbg.NewCborWriter(w) 7766 + 7767 + if _, err := cw.Write([]byte{165}); err != nil { 7768 + return err 7769 + } 7770 + 7771 + // t.LexiconTypeID (string) (string) 7772 + if len("$type") > 1000000 { 7773 + return xerrors.Errorf("Value in field \"$type\" was too long") 7774 + } 7775 + 7776 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 7777 + return err 7778 + } 7779 + if _, err := cw.WriteString(string("$type")); err != nil { 7780 + return err 7781 + } 7782 + 7783 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 7784 + return err 7785 + } 7786 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 7787 + return err 7788 + } 7789 + 7790 + // t.Contents (string) (string) 7791 + if len("contents") > 1000000 { 7792 + return xerrors.Errorf("Value in field \"contents\" was too long") 7793 + } 7794 + 7795 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 7796 + return err 7797 + } 7798 + if _, err := cw.WriteString(string("contents")); err != nil { 7799 + return err 7800 + } 7801 + 7802 + if len(t.Contents) > 1000000 { 7803 + return xerrors.Errorf("Value in field t.Contents was too long") 7804 + } 7805 + 7806 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 7807 + return err 7808 + } 7809 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 7810 + return err 7811 + } 7812 + 7813 + // t.Filename (string) (string) 7814 + if len("filename") > 1000000 { 7815 + return xerrors.Errorf("Value in field \"filename\" was too long") 7816 + } 7817 + 7818 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 7819 + return err 7820 + } 7821 + if _, err := cw.WriteString(string("filename")); err != nil { 7822 + return err 7823 + } 7824 + 7825 + if len(t.Filename) > 1000000 { 7826 + return xerrors.Errorf("Value in field t.Filename was too long") 7827 + } 7828 + 7829 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 7830 + return err 7831 + } 7832 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 7833 + return err 7834 + } 7835 + 7836 + // t.CreatedAt (string) (string) 7837 + if len("createdAt") > 1000000 { 7838 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7839 + } 7840 + 7841 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7842 + return err 7843 + } 7844 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7845 + return err 7846 + } 7847 + 7848 + if len(t.CreatedAt) > 1000000 { 7849 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7850 + } 7851 + 7852 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7853 + return err 7854 + } 7855 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7856 + return err 7857 + } 7858 + 7859 + // t.Description (string) (string) 7860 + if len("description") > 1000000 { 7861 + return xerrors.Errorf("Value in field \"description\" was too long") 7862 + } 7863 + 7864 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 7865 + return err 7866 + } 7867 + if _, err := cw.WriteString(string("description")); err != nil { 7868 + return err 7869 + } 7870 + 7871 + if len(t.Description) > 1000000 { 7872 + return xerrors.Errorf("Value in field t.Description was too long") 7873 + } 7874 + 7875 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 7876 + return err 7877 + } 7878 + if _, err := cw.WriteString(string(t.Description)); err != nil { 7879 + return err 7880 + } 7881 + return nil 7882 + } 7883 + 7884 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 7885 + *t = String{} 7886 + 7887 + cr := cbg.NewCborReader(r) 7888 + 7889 + maj, extra, err := cr.ReadHeader() 7890 + if err != nil { 7891 + return err 7892 + } 7893 + defer func() { 7894 + if err == io.EOF { 7895 + err = io.ErrUnexpectedEOF 7896 + } 7897 + }() 7898 + 7899 + if maj != cbg.MajMap { 7900 + return fmt.Errorf("cbor input should be of type map") 7901 + } 7902 + 7903 + if extra > cbg.MaxLength { 7904 + return fmt.Errorf("String: map struct too large (%d)", extra) 7905 + } 7906 + 7907 + n := extra 7908 + 7909 + nameBuf := make([]byte, 11) 7910 + for i := uint64(0); i < n; i++ { 7911 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7912 + if err != nil { 7913 + return err 7914 + } 7915 + 7916 + if !ok { 7917 + // Field doesn't exist on this type, so ignore it 7918 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7919 + return err 7920 + } 7921 + continue 7922 + } 7923 + 7924 + switch string(nameBuf[:nameLen]) { 7925 + // t.LexiconTypeID (string) (string) 7926 + case "$type": 7927 + 7928 + { 7929 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7930 + if err != nil { 7931 + return err 7932 + } 7933 + 7934 + t.LexiconTypeID = string(sval) 7935 + } 7936 + // t.Contents (string) (string) 7937 + case "contents": 7938 + 7939 + { 7940 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7941 + if err != nil { 7942 + return err 7943 + } 7944 + 7945 + t.Contents = string(sval) 7946 + } 7947 + // t.Filename (string) (string) 7948 + case "filename": 7949 + 7950 + { 7951 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7952 + if err != nil { 7953 + return err 7954 + } 7955 + 7956 + t.Filename = string(sval) 7957 + } 7958 + // t.CreatedAt (string) (string) 7959 + case "createdAt": 7960 + 7961 + { 7962 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7963 + if err != nil { 7964 + return err 7965 + } 7966 + 7967 + t.CreatedAt = string(sval) 7968 + } 7969 + // t.Description (string) (string) 7970 + case "description": 7971 + 7972 + { 7973 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7974 + if err != nil { 7975 + return err 7976 + } 7977 + 7978 + t.Description = string(sval) 7979 + } 7980 + 7981 + default: 7982 + // Field doesn't exist on this type, so ignore it 7983 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7984 + return err 7985 + } 7986 + } 7987 + } 7988 + 7989 + return nil 7990 + }
+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 + }
-1
api/tangled/issuecomment.go
··· 19 19 type RepoIssueComment struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 23 Issue string `json:"issue" cborgen:"issue"` 25 24 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
+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 + }
-1
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 23 Owner string `json:"owner" cborgen:"owner"` 25 24 Repo string `json:"repo" cborgen:"repo"` 26 25 Title string `json:"title" cborgen:"title"`
+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"
+4 -18
api/tangled/tangledpipeline.go
··· 29 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 30 } 31 31 32 - // Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema. 33 - type Pipeline_Dependency struct { 34 - Packages []string `json:"packages" cborgen:"packages"` 35 - Registry string `json:"registry" cborgen:"registry"` 36 - } 37 - 38 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 39 33 type Pipeline_ManualTriggerData struct { 40 34 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 61 55 Ref string `json:"ref" cborgen:"ref"` 62 56 } 63 57 64 - // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 65 - type Pipeline_Step struct { 66 - Command string `json:"command" cborgen:"command"` 67 - Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 - Name string `json:"name" cborgen:"name"` 69 - } 70 - 71 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 72 59 type Pipeline_TriggerMetadata struct { 73 60 Kind string `json:"kind" cborgen:"kind"` ··· 87 74 88 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 89 76 type Pipeline_Workflow struct { 90 - Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 91 - Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"` 92 - Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"` 93 - Name string `json:"name" cborgen:"name"` 94 - Steps []*Pipeline_Step `json:"steps" cborgen:"steps"` 77 + Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 78 + Engine string `json:"engine" cborgen:"engine"` 79 + Name string `json:"name" cborgen:"name"` 80 + Raw string `json:"raw" cborgen:"raw"` 95 81 }
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+1
appview/cache/session/store.go
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+21 -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"` 19 + 20 + // temporarily, to add users to default spindle 21 + AppPassword string `env:"APP_PASSWORD"` 18 22 } 19 23 20 24 type OAuthConfig struct { ··· 59 63 DB int `env:"DB, default=0"` 60 64 } 61 65 66 + type PdsConfig struct { 67 + Host string `env:"HOST, default=https://tngl.sh"` 68 + AdminSecret string `env:"ADMIN_SECRET"` 69 + } 70 + 71 + type Cloudflare struct { 72 + ApiToken string `env:"API_TOKEN"` 73 + ZoneId string `env:"ZONE_ID"` 74 + } 75 + 62 76 func (cfg RedisConfig) ToURL() string { 63 77 u := &url.URL{ 64 78 Scheme: "redis", ··· 84 98 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 99 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 100 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 101 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 102 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 103 } 88 104 89 105 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 + }
+153 -27
appview/db/db.go
··· 27 27 } 28 28 29 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 30 + // https://github.com/mattn/go-sqlite3#connection-string 31 + opts := []string{ 32 + "_foreign_keys=1", 33 + "_journal_mode=WAL", 34 + "_synchronous=NORMAL", 35 + "_auto_vacuum=incremental", 36 + } 37 + 38 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + ctx := context.Background() 44 + 45 + conn, err := db.Conn(ctx) 31 46 if err != nil { 32 47 return nil, err 33 48 } 34 - _, err = db.Exec(` 35 - pragma journal_mode = WAL; 36 - pragma synchronous = normal; 37 - pragma foreign_keys = on; 38 - pragma temp_store = memory; 39 - pragma mmap_size = 30000000000; 40 - pragma page_size = 32768; 41 - pragma auto_vacuum = incremental; 42 - pragma busy_timeout = 5000; 49 + defer conn.Close() 43 50 51 + _, err = conn.ExecContext(ctx, ` 44 52 create table if not exists registrations ( 45 53 id integer primary key autoincrement, 46 54 domain text not null unique, ··· 199 207 unique(starred_by_did, repo_at) 200 208 ); 201 209 210 + create table if not exists reactions ( 211 + id integer primary key autoincrement, 212 + reacted_by_did text not null, 213 + thread_at text not null, 214 + kind text not null, 215 + rkey text not null, 216 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 217 + unique(reacted_by_did, thread_at, kind) 218 + ); 219 + 202 220 create table if not exists emails ( 203 221 id integer primary key autoincrement, 204 222 did text not null, ··· 345 363 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 346 364 347 365 -- constraints 348 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 349 366 unique (did, instance, subject) 350 367 ); 351 368 ··· 411 428 on delete cascade 412 429 ); 413 430 431 + create table if not exists repo_languages ( 432 + -- identifiers 433 + id integer primary key autoincrement, 434 + 435 + -- repo identifiers 436 + repo_at text not null, 437 + ref text not null, 438 + is_default_ref integer not null default 0, 439 + 440 + -- language breakdown 441 + language text not null, 442 + bytes integer not null check (bytes >= 0), 443 + 444 + unique(repo_at, ref, language) 445 + ); 446 + 447 + create table if not exists signups_inflight ( 448 + id integer primary key autoincrement, 449 + email text not null unique, 450 + invite_code text not null, 451 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 452 + ); 453 + 454 + create table if not exists strings ( 455 + -- identifiers 456 + did text not null, 457 + rkey text not null, 458 + 459 + -- content 460 + filename text not null, 461 + description text, 462 + content text not null, 463 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 464 + edited text, 465 + 466 + primary key (did, rkey) 467 + ); 468 + 414 469 create table if not exists migrations ( 415 470 id integer primary key autoincrement, 416 471 name text unique 417 472 ); 473 + 474 + -- indexes for better star query performance 475 + create index if not exists idx_stars_created on stars(created); 476 + create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 418 477 `) 419 478 if err != nil { 420 479 return nil, err 421 480 } 422 481 423 482 // run migrations 424 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 425 484 tx.Exec(` 426 485 alter table repos add column description text check (length(description) <= 200); 427 486 `) 428 487 return nil 429 488 }) 430 489 431 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 432 491 // add unconstrained column 433 492 _, err := tx.Exec(` 434 493 alter table public_keys ··· 451 510 return nil 452 511 }) 453 512 454 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 455 514 _, err := tx.Exec(` 456 515 alter table comments drop column comment_at; 457 516 alter table comments add column rkey text; ··· 459 518 return err 460 519 }) 461 520 462 - runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 521 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 463 522 _, err := tx.Exec(` 464 523 alter table comments add column deleted text; -- timestamp 465 524 alter table comments add column edited text; -- timestamp ··· 467 526 return err 468 527 }) 469 528 470 - runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 529 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 471 530 _, err := tx.Exec(` 472 531 alter table pulls add column source_branch text; 473 532 alter table pulls add column source_repo_at text; ··· 476 535 return err 477 536 }) 478 537 479 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 480 539 _, err := tx.Exec(` 481 540 alter table repos add column source text; 482 541 `) ··· 487 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 488 547 // 489 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 490 - db.Exec("pragma foreign_keys = off;") 491 - runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 549 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 550 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 492 551 _, err := tx.Exec(` 493 552 create table pulls_new ( 494 553 -- identifiers ··· 543 602 `) 544 603 return err 545 604 }) 546 - db.Exec("pragma foreign_keys = on;") 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 547 606 548 607 // run migrations 549 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 550 609 tx.Exec(` 551 610 alter table repos add column spindle text; 552 611 `) 553 612 return nil 554 613 }) 555 614 615 + // recreate and add rkey + created columns with default constraint 616 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 617 + // create new table 618 + // - repo_at instead of repo integer 619 + // - rkey field 620 + // - created field 621 + _, err := tx.Exec(` 622 + create table collaborators_new ( 623 + -- identifiers for the record 624 + id integer primary key autoincrement, 625 + did text not null, 626 + rkey text, 627 + 628 + -- content 629 + subject_did text not null, 630 + repo_at text not null, 631 + 632 + -- meta 633 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 634 + 635 + -- constraints 636 + foreign key (repo_at) references repos(at_uri) on delete cascade 637 + ) 638 + `) 639 + if err != nil { 640 + return err 641 + } 642 + 643 + // copy data 644 + _, err = tx.Exec(` 645 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 646 + select 647 + c.id, 648 + r.did, 649 + '', 650 + c.did, 651 + r.at_uri 652 + from collaborators c 653 + join repos r on c.repo = r.id 654 + `) 655 + if err != nil { 656 + return err 657 + } 658 + 659 + // drop old table 660 + _, err = tx.Exec(`drop table collaborators`) 661 + if err != nil { 662 + return err 663 + } 664 + 665 + // rename new table 666 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 667 + return err 668 + }) 669 + 670 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 671 + _, err := tx.Exec(` 672 + alter table issues add column rkey text not null default ''; 673 + 674 + -- get last url section from issue_at and save to rkey column 675 + update issues 676 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 677 + `) 678 + return err 679 + }) 680 + 556 681 return &DB{db}, nil 557 682 } 558 683 559 684 type migrationFn = func(*sql.Tx) error 560 685 561 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 562 - tx, err := d.Begin() 686 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 687 + tx, err := c.BeginTx(context.Background(), nil) 563 688 if err != nil { 564 689 return err 565 690 } ··· 626 751 kind := rv.Kind() 627 752 628 753 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 629 - if kind == reflect.Slice || kind == reflect.Array { 754 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 630 755 if rv.Len() == 0 { 631 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 756 + // always false 757 + return "1 = 0" 632 758 } 633 759 634 760 placeholders := make([]string, rv.Len()) ··· 645 771 func (f filter) Arg() []any { 646 772 rv := reflect.ValueOf(f.arg) 647 773 kind := rv.Kind() 648 - if kind == reflect.Slice || kind == reflect.Array { 774 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 649 775 if rv.Len() == 0 { 650 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 776 + return nil 651 777 } 652 778 653 779 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 }
+3 -3
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 ··· 53 53 return err 54 54 } 55 55 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 56 + func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 57 57 followers, following := 0, 0 58 58 err := e.QueryRow( 59 59 `SELECT
+220 -24
appview/db/issues.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + mathrand "math/rand/v2" 7 + "strings" 5 8 "time" 6 9 7 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.sh/tangled.sh/core/api/tangled" 8 12 "tangled.sh/tangled.sh/core/appview/pagination" 9 13 ) 10 14 11 15 type Issue struct { 16 + ID int64 12 17 RepoAt syntax.ATURI 13 18 OwnerDid string 14 19 IssueId int 15 - IssueAt string 20 + Rkey string 16 21 Created time.Time 17 22 Title string 18 23 Body string ··· 41 46 Edited *time.Time 42 47 } 43 48 49 + func (i *Issue) AtUri() syntax.ATURI { 50 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 + } 52 + 53 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 + if err != nil { 56 + created = time.Now() 57 + } 58 + 59 + body := "" 60 + if record.Body != nil { 61 + body = *record.Body 62 + } 63 + 64 + return Issue{ 65 + RepoAt: syntax.ATURI(record.Repo), 66 + OwnerDid: record.Owner, 67 + Rkey: rkey, 68 + Created: created, 69 + Title: record.Title, 70 + Body: body, 71 + Open: true, // new issues are open by default 72 + } 73 + } 74 + 75 + func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 + ownerDid := issueUri.Authority().String() 77 + issueRkey := issueUri.RecordKey().String() 78 + 79 + var repoAt string 80 + var issueId int 81 + 82 + query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 + err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 + if err != nil { 85 + return "", 0, err 86 + } 87 + 88 + return syntax.ATURI(repoAt), issueId, nil 89 + } 90 + 91 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + ownerDid := did 98 + if record.Owner != nil { 99 + ownerDid = *record.Owner 100 + } 101 + 102 + issueUri, err := syntax.ParseATURI(record.Issue) 103 + if err != nil { 104 + return Comment{}, err 105 + } 106 + 107 + repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 + if err != nil { 109 + return Comment{}, err 110 + } 111 + 112 + comment := Comment{ 113 + OwnerDid: ownerDid, 114 + RepoAt: repoAt, 115 + Rkey: rkey, 116 + Body: record.Body, 117 + Issue: issueId, 118 + CommentId: mathrand.IntN(1000000), 119 + Created: &created, 120 + } 121 + 122 + return comment, nil 123 + } 124 + 44 125 func NewIssue(tx *sql.Tx, issue *Issue) error { 45 126 defer tx.Rollback() 46 127 ··· 65 146 66 147 issue.IssueId = nextId 67 148 68 - _, err = tx.Exec(` 69 - insert into issues (repo_at, owner_did, issue_id, title, body) 70 - values (?, ?, ?, ?, ?) 71 - `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 149 + res, err := tx.Exec(` 150 + insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 151 + values (?, ?, ?, ?, ?, ?, ?) 152 + `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 153 + if err != nil { 154 + return err 155 + } 156 + 157 + lastID, err := res.LastInsertId() 72 158 if err != nil { 73 159 return err 74 160 } 161 + issue.ID = lastID 75 162 76 163 if err := tx.Commit(); err != nil { 77 164 return err ··· 80 167 return nil 81 168 } 82 169 83 - func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error { 84 - _, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId) 85 - return err 86 - } 87 - 88 170 func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 89 171 var issueAt string 90 172 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 173 return issueAt, err 92 174 } 93 175 94 - func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) { 95 - var issueId int 96 - err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId) 97 - return issueId - 1, err 98 - } 99 - 100 176 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 101 177 var ownerDid string 102 178 err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 103 179 return ownerDid, err 104 180 } 105 181 106 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 182 + func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 107 183 var issues []Issue 108 184 openValue := 0 109 185 if isOpen { ··· 114 190 ` 115 191 with numbered_issue as ( 116 192 select 193 + i.id, 117 194 i.owner_did, 195 + i.rkey, 118 196 i.issue_id, 119 197 i.created, 120 198 i.title, ··· 132 210 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 211 ) 134 212 select 213 + id, 135 214 owner_did, 215 + rkey, 136 216 issue_id, 137 217 created, 138 218 title, 139 219 body, 140 220 open, 141 221 comment_count 142 - from 222 + from 143 223 numbered_issue 144 - where 224 + where 145 225 row_num between ? and ?`, 146 226 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 147 227 if err != nil { ··· 153 233 var issue Issue 154 234 var createdAt string 155 235 var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 236 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 237 if err != nil { 158 238 return nil, err 159 239 } ··· 175 255 return issues, nil 176 256 } 177 257 258 + func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 + issues := make([]Issue, 0, limit) 260 + 261 + var conditions []string 262 + var args []any 263 + for _, filter := range filters { 264 + conditions = append(conditions, filter.Condition()) 265 + args = append(args, filter.Arg()...) 266 + } 267 + 268 + whereClause := "" 269 + if conditions != nil { 270 + whereClause = " where " + strings.Join(conditions, " and ") 271 + } 272 + limitClause := "" 273 + if limit != 0 { 274 + limitClause = fmt.Sprintf(" limit %d ", limit) 275 + } 276 + 277 + query := fmt.Sprintf( 278 + `select 279 + i.id, 280 + i.owner_did, 281 + i.repo_at, 282 + i.issue_id, 283 + i.created, 284 + i.title, 285 + i.body, 286 + i.open 287 + from 288 + issues i 289 + %s 290 + order by 291 + i.created desc 292 + %s`, 293 + whereClause, limitClause) 294 + 295 + rows, err := e.Query(query, args...) 296 + if err != nil { 297 + return nil, err 298 + } 299 + defer rows.Close() 300 + 301 + for rows.Next() { 302 + var issue Issue 303 + var issueCreatedAt string 304 + err := rows.Scan( 305 + &issue.ID, 306 + &issue.OwnerDid, 307 + &issue.RepoAt, 308 + &issue.IssueId, 309 + &issueCreatedAt, 310 + &issue.Title, 311 + &issue.Body, 312 + &issue.Open, 313 + ) 314 + if err != nil { 315 + return nil, err 316 + } 317 + 318 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 + if err != nil { 320 + return nil, err 321 + } 322 + issue.Created = issueCreatedTime 323 + 324 + issues = append(issues, issue) 325 + } 326 + 327 + if err := rows.Err(); err != nil { 328 + return nil, err 329 + } 330 + 331 + return issues, nil 332 + } 333 + 334 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 + return GetIssuesWithLimit(e, 0, filters...) 336 + } 337 + 178 338 // timeframe here is directly passed into the sql query filter, and any 179 339 // timeframe in the past should be negative; e.g.: "-3 months" 180 340 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 182 342 183 343 rows, err := e.Query( 184 344 `select 345 + i.id, 185 346 i.owner_did, 347 + i.rkey, 186 348 i.repo_at, 187 349 i.issue_id, 188 350 i.created, ··· 213 375 var issueCreatedAt, repoCreatedAt string 214 376 var repo Repo 215 377 err := rows.Scan( 378 + &issue.ID, 216 379 &issue.OwnerDid, 380 + &issue.Rkey, 217 381 &issue.RepoAt, 218 382 &issue.IssueId, 219 383 &issueCreatedAt, ··· 257 421 } 258 422 259 423 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 = ?` 424 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 261 425 row := e.QueryRow(query, repoAt, issueId) 262 426 263 427 var issue Issue 264 428 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 429 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 430 if err != nil { 267 431 return nil, err 268 432 } ··· 277 441 } 278 442 279 443 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 = ?` 444 + query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 281 445 row := e.QueryRow(query, repoAt, issueId) 282 446 283 447 var issue Issue 284 448 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 449 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 286 450 if err != nil { 287 451 return nil, nil, err 288 452 } ··· 459 623 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 460 624 where repo_at = ? and issue_id = ? and comment_id = ? 461 625 `, repoAt, issueId, commentId) 626 + return err 627 + } 628 + 629 + func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 + _, err := e.Exec( 631 + ` 632 + update comments 633 + set body = ?, 634 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 + where owner_did = ? and rkey = ? 636 + `, newBody, ownerDid, rkey) 637 + return err 638 + } 639 + 640 + func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 + _, err := e.Exec( 642 + ` 643 + update comments 644 + set body = "", 645 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 + where owner_did = ? and rkey = ? 647 + `, ownerDid, rkey) 648 + return err 649 + } 650 + 651 + func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 + _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 + return err 654 + } 655 + 656 + func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 + _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 462 658 return err 463 659 } 464 660
+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 + }
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+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
+22 -3
appview/db/pulls.go
··· 310 310 return pullId - 1, err 311 311 } 312 312 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 313 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 314 314 pulls := make(map[int]*Pull) 315 315 316 316 var conditions []string ··· 323 323 whereClause := "" 324 324 if conditions != nil { 325 325 whereClause = " where " + strings.Join(conditions, " and ") 326 + } 327 + limitClause := "" 328 + if limit != 0 { 329 + limitClause = fmt.Sprintf(" limit %d ", limit) 326 330 } 327 331 328 332 query := fmt.Sprintf(` ··· 344 348 from 345 349 pulls 346 350 %s 347 - `, whereClause) 351 + order by 352 + created desc 353 + %s 354 + `, whereClause, limitClause) 348 355 349 356 rows, err := e.Query(query, args...) 350 357 if err != nil { ··· 412 419 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 420 submissionsQuery := fmt.Sprintf(` 414 421 select 415 - id, pull_id, round_number, patch, source_rev 422 + id, pull_id, round_number, patch, created, source_rev 416 423 from 417 424 pull_submissions 418 425 where ··· 438 445 for submissionsRows.Next() { 439 446 var s PullSubmission 440 447 var sourceRev sql.NullString 448 + var createdAt string 441 449 err := submissionsRows.Scan( 442 450 &s.ID, 443 451 &s.PullId, 444 452 &s.RoundNumber, 445 453 &s.Patch, 454 + &createdAt, 446 455 &sourceRev, 447 456 ) 448 457 if err != nil { 449 458 return nil, err 450 459 } 460 + 461 + createdTime, err := time.Parse(time.RFC3339, createdAt) 462 + if err != nil { 463 + return nil, err 464 + } 465 + s.Created = createdTime 451 466 452 467 if sourceRev.Valid { 453 468 s.SourceRev = sourceRev.String ··· 511 526 }) 512 527 513 528 return orderedByPullId, nil 529 + } 530 + 531 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 532 + return GetPullsWithLimit(e, 0, filters...) 514 533 } 515 534 516 535 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+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 ReactionKind = "๐Ÿ‘Ž" 15 + Laugh ReactionKind = "๐Ÿ˜†" 16 + Celebration ReactionKind = "๐ŸŽ‰" 17 + Confused ReactionKind = "๐Ÿซค" 18 + Heart ReactionKind = "โค๏ธ" 19 + Rocket ReactionKind = "๐Ÿš€" 20 + Eyes ReactionKind = "๐Ÿ‘€" 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 {
+78 -81
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 ··· 17 19 Knot string 18 20 Rkey string 19 21 Created time.Time 20 - AtUri string 21 22 Description string 22 23 Spindle string 23 24 ··· 71 72 return repos, nil 72 73 } 73 74 74 - func GetRepos(e Execer, filters ...filter) ([]Repo, error) { 75 - repoMap := make(map[syntax.ATURI]Repo) 75 + func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 76 + repoMap := make(map[syntax.ATURI]*Repo) 76 77 77 78 var conditions []string 78 79 var args []any ··· 86 87 whereClause = " where " + strings.Join(conditions, " and ") 87 88 } 88 89 90 + limitClause := "" 91 + if limit != 0 { 92 + limitClause = fmt.Sprintf(" limit %d", limit) 93 + } 94 + 89 95 repoQuery := fmt.Sprintf( 90 96 `select 91 97 did, ··· 98 104 spindle 99 105 from 100 106 repos r 107 + %s 108 + order by created desc 101 109 %s`, 102 110 whereClause, 111 + limitClause, 103 112 ) 104 113 rows, err := e.Query(repoQuery, args...) 105 114 ··· 139 148 repo.Spindle = spindle.String 140 149 } 141 150 142 - repoMap[repo.RepoAt()] = repo 151 + repo.RepoStats = &RepoStats{} 152 + repoMap[repo.RepoAt()] = &repo 143 153 } 144 154 145 155 if err = rows.Err(); err != nil { ··· 148 158 149 159 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 150 160 args = make([]any, len(repoMap)) 161 + 162 + i := 0 151 163 for _, r := range repoMap { 152 - args = append(args, r.RepoAt()) 164 + args[i] = r.RepoAt() 165 + i++ 166 + } 167 + 168 + languageQuery := fmt.Sprintf( 169 + ` 170 + select 171 + repo_at, language 172 + from 173 + repo_languages r1 174 + where 175 + repo_at IN (%s) 176 + and is_default_ref = 1 177 + and id = ( 178 + select id 179 + from repo_languages r2 180 + where r2.repo_at = r1.repo_at 181 + and r2.is_default_ref = 1 182 + order by bytes desc 183 + limit 1 184 + ); 185 + `, 186 + inClause, 187 + ) 188 + rows, err = e.Query(languageQuery, args...) 189 + if err != nil { 190 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 191 + } 192 + for rows.Next() { 193 + var repoat, lang string 194 + if err := rows.Scan(&repoat, &lang); err != nil { 195 + log.Println("err", "err", err) 196 + continue 197 + } 198 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 199 + r.RepoStats.Language = lang 200 + } 201 + } 202 + if err = rows.Err(); err != nil { 203 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 153 204 } 154 205 155 206 starCountQuery := fmt.Sprintf( ··· 168 219 var repoat string 169 220 var count int 170 221 if err := rows.Scan(&repoat, &count); err != nil { 222 + log.Println("err", "err", err) 171 223 continue 172 224 } 173 225 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 196 248 var repoat string 197 249 var open, closed int 198 250 if err := rows.Scan(&repoat, &open, &closed); err != nil { 251 + log.Println("err", "err", err) 199 252 continue 200 253 } 201 254 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 236 289 var repoat string 237 290 var open, merged, closed, deleted int 238 291 if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 292 + log.Println("err", "err", err) 239 293 continue 240 294 } 241 295 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 251 305 252 306 var repos []Repo 253 307 for _, r := range repoMap { 254 - repos = append(repos, r) 308 + repos = append(repos, *r) 255 309 } 256 310 311 + slices.SortFunc(repos, func(a, b Repo) int { 312 + if a.Created.After(b.Created) { 313 + return 1 314 + } 315 + return -1 316 + }) 317 + 257 318 return repos, nil 258 319 } 259 320 ··· 329 390 var description, spindle sql.NullString 330 391 331 392 row := e.QueryRow(` 332 - select did, name, knot, created, at_uri, description, spindle 393 + select did, name, knot, created, description, spindle, rkey 333 394 from repos 334 395 where did = ? and name = ? 335 396 `, ··· 338 399 ) 339 400 340 401 var createdAt string 341 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 402 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 342 403 return nil, err 343 404 } 344 405 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 359 420 var repo Repo 360 421 var nullableDescription sql.NullString 361 422 362 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 423 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 363 424 364 425 var createdAt string 365 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 426 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 366 427 return nil, err 367 428 } 368 429 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 382 443 `insert into repos 383 444 (did, name, knot, rkey, at_uri, description, source) 384 445 values (?, ?, ?, ?, ?, ?, ?)`, 385 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 446 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 386 447 ) 387 448 return err 388 449 } ··· 405 466 var repos []Repo 406 467 407 468 rows, err := e.Query( 408 - `select did, name, knot, rkey, description, created, at_uri, source 469 + `select did, name, knot, rkey, description, created, source 409 470 from repos 410 471 where did = ? and source is not null and source != '' 411 472 order by created desc`, ··· 422 483 var nullableDescription sql.NullString 423 484 var nullableSource sql.NullString 424 485 425 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 486 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 426 487 if err != nil { 427 488 return nil, err 428 489 } ··· 459 520 var nullableSource sql.NullString 460 521 461 522 row := e.QueryRow( 462 - `select did, name, knot, rkey, description, created, at_uri, source 523 + `select did, name, knot, rkey, description, created, source 463 524 from repos 464 525 where did = ? and name = ? and source is not null and source != ''`, 465 526 did, name, 466 527 ) 467 528 468 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 529 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 469 530 if err != nil { 470 531 return nil, err 471 532 } ··· 488 549 return &repo, nil 489 550 } 490 551 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 552 func UpdateDescription(e Execer, repoAt, newDescription string) error { 500 553 _, err := e.Exec( 501 554 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 502 555 return err 503 556 } 504 557 505 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 558 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 506 559 _, err := e.Exec( 507 560 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 508 561 return err 509 562 } 510 563 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 564 type RepoStats struct { 565 + Language string 569 566 StarCount int 570 567 IssueCount IssueCount 571 568 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 + }
+164 -7
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 40 47 // Get a star record 41 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 42 49 query := ` 43 - select starred_by_did, repo_at, created, rkey 50 + select starred_by_did, repo_at, created, rkey 44 51 from stars 45 52 where starred_by_did = ? and repo_at = ?` 46 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 93 100 } 94 101 } 95 102 103 + func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 104 + var conditions []string 105 + var args []any 106 + for _, filter := range filters { 107 + conditions = append(conditions, filter.Condition()) 108 + args = append(args, filter.Arg()...) 109 + } 110 + 111 + whereClause := "" 112 + if conditions != nil { 113 + whereClause = " where " + strings.Join(conditions, " and ") 114 + } 115 + 116 + limitClause := "" 117 + if limit != 0 { 118 + limitClause = fmt.Sprintf(" limit %d", limit) 119 + } 120 + 121 + repoQuery := fmt.Sprintf( 122 + `select starred_by_did, repo_at, created, rkey 123 + from stars 124 + %s 125 + order by created desc 126 + %s`, 127 + whereClause, 128 + limitClause, 129 + ) 130 + rows, err := e.Query(repoQuery, args...) 131 + if err != nil { 132 + return nil, err 133 + } 134 + 135 + starMap := make(map[string][]Star) 136 + for rows.Next() { 137 + var star Star 138 + var created string 139 + err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 140 + if err != nil { 141 + return nil, err 142 + } 143 + 144 + star.Created = time.Now() 145 + if t, err := time.Parse(time.RFC3339, created); err == nil { 146 + star.Created = t 147 + } 148 + 149 + repoAt := string(star.RepoAt) 150 + starMap[repoAt] = append(starMap[repoAt], star) 151 + } 152 + 153 + // populate *Repo in each star 154 + args = make([]any, len(starMap)) 155 + i := 0 156 + for r := range starMap { 157 + args[i] = r 158 + i++ 159 + } 160 + 161 + if len(args) == 0 { 162 + return nil, nil 163 + } 164 + 165 + repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 166 + if err != nil { 167 + return nil, err 168 + } 169 + 170 + for _, r := range repos { 171 + if stars, ok := starMap[string(r.RepoAt())]; ok { 172 + for i := range stars { 173 + stars[i].Repo = &r 174 + } 175 + } 176 + } 177 + 178 + var stars []Star 179 + for _, s := range starMap { 180 + stars = append(stars, s...) 181 + } 182 + 183 + return stars, nil 184 + } 185 + 96 186 func GetAllStars(e Execer, limit int) ([]Star, error) { 97 187 var stars []Star 98 188 99 189 rows, err := e.Query(` 100 - select 190 + select 101 191 s.starred_by_did, 102 192 s.repo_at, 103 193 s.rkey, ··· 106 196 r.name, 107 197 r.knot, 108 198 r.rkey, 109 - r.created, 110 - r.at_uri 199 + r.created 111 200 from stars s 112 201 join repos r on s.repo_at = r.at_uri 113 202 `) ··· 132 221 &repo.Knot, 133 222 &repo.Rkey, 134 223 &repoCreatedAt, 135 - &repo.AtUri, 136 224 ); err != nil { 137 225 return nil, err 138 226 } ··· 156 244 157 245 return stars, nil 158 246 } 247 + 248 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 250 + // first, get the top repo URIs by star count from the last week 251 + query := ` 252 + with recent_starred_repos as ( 253 + select distinct repo_at 254 + from stars 255 + where created >= datetime('now', '-7 days') 256 + ), 257 + repo_star_counts as ( 258 + select 259 + s.repo_at, 260 + count(*) as star_count 261 + from stars s 262 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 263 + group by s.repo_at 264 + ) 265 + select rsc.repo_at 266 + from repo_star_counts rsc 267 + order by rsc.star_count desc 268 + limit 8 269 + ` 270 + 271 + rows, err := e.Query(query) 272 + if err != nil { 273 + return nil, err 274 + } 275 + defer rows.Close() 276 + 277 + var repoUris []string 278 + for rows.Next() { 279 + var repoUri string 280 + err := rows.Scan(&repoUri) 281 + if err != nil { 282 + return nil, err 283 + } 284 + repoUris = append(repoUris, repoUri) 285 + } 286 + 287 + if err := rows.Err(); err != nil { 288 + return nil, err 289 + } 290 + 291 + if len(repoUris) == 0 { 292 + return []Repo{}, nil 293 + } 294 + 295 + // get full repo data 296 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 297 + if err != nil { 298 + return nil, err 299 + } 300 + 301 + // sort repos by the original trending order 302 + repoMap := make(map[string]Repo) 303 + for _, repo := range repos { 304 + repoMap[repo.RepoAt().String()] = repo 305 + } 306 + 307 + orderedRepos := make([]Repo, 0, len(repoUris)) 308 + for _, uri := range repoUris { 309 + if repo, exists := repoMap[uri]; exists { 310 + orderedRepos = append(orderedRepos, repo) 311 + } 312 + } 313 + 314 + return orderedRepos, nil 315 + }
+252
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if utf8.RuneCountInString(s.Filename) > 140 { 54 + err = errors.Join(err, fmt.Errorf("filename too long")) 55 + } 56 + 57 + if utf8.RuneCountInString(s.Description) > 280 { 58 + err = errors.Join(err, fmt.Errorf("description too long")) 59 + } 60 + 61 + if len(s.Contents) == 0 { 62 + err = errors.Join(err, fmt.Errorf("contents is empty")) 63 + } 64 + 65 + return err 66 + } 67 + 68 + func (s *String) AsRecord() tangled.String { 69 + return tangled.String{ 70 + Filename: s.Filename, 71 + Description: s.Description, 72 + Contents: s.Contents, 73 + CreatedAt: s.Created.Format(time.RFC3339), 74 + } 75 + } 76 + 77 + func StringFromRecord(did, rkey string, record tangled.String) String { 78 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 + if err != nil { 80 + created = time.Now() 81 + } 82 + return String{ 83 + Did: syntax.DID(did), 84 + Rkey: rkey, 85 + Filename: record.Filename, 86 + Description: record.Description, 87 + Contents: record.Contents, 88 + Created: created, 89 + } 90 + } 91 + 92 + func AddString(e Execer, s String) error { 93 + _, err := e.Exec( 94 + `insert into strings ( 95 + did, 96 + rkey, 97 + filename, 98 + description, 99 + content, 100 + created, 101 + edited 102 + ) 103 + values (?, ?, ?, ?, ?, ?, null) 104 + on conflict(did, rkey) do update set 105 + filename = excluded.filename, 106 + description = excluded.description, 107 + content = excluded.content, 108 + edited = case 109 + when 110 + strings.content != excluded.content 111 + or strings.filename != excluded.filename 112 + or strings.description != excluded.description then ? 113 + else strings.edited 114 + end`, 115 + s.Did, 116 + s.Rkey, 117 + s.Filename, 118 + s.Description, 119 + s.Contents, 120 + s.Created.Format(time.RFC3339), 121 + time.Now().Format(time.RFC3339), 122 + ) 123 + return err 124 + } 125 + 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 + var all []String 128 + 129 + var conditions []string 130 + var args []any 131 + for _, filter := range filters { 132 + conditions = append(conditions, filter.Condition()) 133 + args = append(args, filter.Arg()...) 134 + } 135 + 136 + whereClause := "" 137 + if conditions != nil { 138 + whereClause = " where " + strings.Join(conditions, " and ") 139 + } 140 + 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 146 + query := fmt.Sprintf(`select 147 + did, 148 + rkey, 149 + filename, 150 + description, 151 + content, 152 + created, 153 + edited 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 + whereClause, 159 + limitClause, 160 + ) 161 + 162 + rows, err := e.Query(query, args...) 163 + 164 + if err != nil { 165 + return nil, err 166 + } 167 + defer rows.Close() 168 + 169 + for rows.Next() { 170 + var s String 171 + var createdAt string 172 + var editedAt sql.NullString 173 + 174 + if err := rows.Scan( 175 + &s.Did, 176 + &s.Rkey, 177 + &s.Filename, 178 + &s.Description, 179 + &s.Contents, 180 + &createdAt, 181 + &editedAt, 182 + ); err != nil { 183 + return nil, err 184 + } 185 + 186 + s.Created, err = time.Parse(time.RFC3339, createdAt) 187 + if err != nil { 188 + s.Created = time.Now() 189 + } 190 + 191 + if editedAt.Valid { 192 + e, err := time.Parse(time.RFC3339, editedAt.String) 193 + if err != nil { 194 + e = time.Now() 195 + } 196 + s.Edited = &e 197 + } 198 + 199 + all = append(all, s) 200 + } 201 + 202 + if err := rows.Err(); err != nil { 203 + return nil, err 204 + } 205 + 206 + return all, nil 207 + } 208 + 209 + func DeleteString(e Execer, filters ...filter) error { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 223 + 224 + _, err := e.Exec(query, args...) 225 + return err 226 + } 227 + 228 + func countLines(r io.Reader) (int, error) { 229 + buf := make([]byte, 32*1024) 230 + bufLen := 0 231 + count := 0 232 + nl := []byte{'\n'} 233 + 234 + for { 235 + c, err := r.Read(buf) 236 + if c > 0 { 237 + bufLen += c 238 + } 239 + count += bytes.Count(buf[:c], nl) 240 + 241 + switch { 242 + case err == io.EOF: 243 + /* handle last line not having a newline at the end */ 244 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 245 + count++ 246 + } 247 + return count, nil 248 + case err != nil: 249 + return 0, err 250 + } 251 + } 252 + }
+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 := GetFollowerFollowingCount(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 - }
+291 -32
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 15 "tangled.sh/tangled.sh/core/api/tangled" 15 16 "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 18 19 "tangled.sh/tangled.sh/core/appview/spindleverify" 20 + "tangled.sh/tangled.sh/core/idresolver" 19 21 "tangled.sh/tangled.sh/core/rbac" 20 22 ) 21 23 ··· 40 42 } 41 43 }() 42 44 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) 45 + l := i.Logger.With("kind", e.Kind) 46 + switch e.Kind { 47 + case models.EventKindAccount: 48 + if !e.Account.Active && *e.Account.Status == "deactivated" { 49 + err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 50 + } 51 + case models.EventKindIdentity: 52 + err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 53 + case models.EventKindCommit: 54 + switch e.Commit.Collection { 55 + case tangled.GraphFollowNSID: 56 + err = i.ingestFollow(e) 57 + case tangled.FeedStarNSID: 58 + err = i.ingestStar(e) 59 + case tangled.PublicKeyNSID: 60 + err = i.ingestPublicKey(e) 61 + case tangled.RepoArtifactNSID: 62 + err = i.ingestArtifact(e) 63 + case tangled.ActorProfileNSID: 64 + err = i.ingestProfile(e) 65 + case tangled.SpindleMemberNSID: 66 + err = i.ingestSpindleMember(ctx, e) 67 + case tangled.SpindleNSID: 68 + err = i.ingestSpindle(ctx, e) 69 + case tangled.StringNSID: 70 + err = i.ingestString(e) 71 + case tangled.RepoIssueNSID: 72 + err = i.ingestIssue(ctx, e) 73 + case tangled.RepoIssueCommentNSID: 74 + err = i.ingestIssueComment(e) 75 + } 76 + l = i.Logger.With("nsid", e.Commit.Collection) 62 77 } 63 78 64 79 if err != nil { 65 - l := i.Logger.With("nsid", e.Commit.Collection) 66 - l.Error("error ingesting record", "err", err) 80 + l.Debug("error ingesting record", "err", err) 67 81 } 68 82 69 - return err 83 + return nil 70 84 } 71 85 } 72 86 ··· 94 108 l.Error("invalid record", "err", err) 95 109 return err 96 110 } 97 - err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey) 111 + err = db.AddStar(i.Db, &db.Star{ 112 + StarredByDid: did, 113 + RepoAt: subjectUri, 114 + Rkey: e.Commit.RKey, 115 + }) 98 116 case models.CommitOperationDelete: 99 117 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 100 118 } ··· 123 141 return err 124 142 } 125 143 126 - subjectDid := record.Subject 127 - err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey) 144 + err = db.AddFollow(i.Db, &db.Follow{ 145 + UserDid: did, 146 + SubjectDid: record.Subject, 147 + Rkey: e.Commit.RKey, 148 + }) 128 149 case models.CommitOperationDelete: 129 150 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 130 151 } ··· 321 342 return nil 322 343 } 323 344 324 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 345 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 325 346 did := e.Did 326 347 var err error 327 348 ··· 344 365 return fmt.Errorf("failed to enforce permissions: %w", err) 345 366 } 346 367 347 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 368 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 348 369 if err != nil { 349 370 return err 350 371 } ··· 372 393 if err != nil { 373 394 return fmt.Errorf("failed to update ACLs: %w", err) 374 395 } 396 + 397 + l.Info("added spindle member") 375 398 case models.CommitOperationDelete: 376 399 rkey := e.Commit.RKey 377 400 ··· 418 441 if err = i.Enforcer.E.SavePolicy(); err != nil { 419 442 return fmt.Errorf("failed to save ACLs: %w", err) 420 443 } 444 + 445 + l.Info("removed spindle member") 421 446 } 422 447 423 448 return nil 424 449 } 425 450 426 - func (i *Ingester) ingestSpindle(e *models.Event) error { 451 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 427 452 did := e.Did 428 453 var err error 429 454 ··· 456 481 return err 457 482 } 458 483 459 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 484 + err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 460 485 if err != nil { 461 486 l.Error("failed to add spindle to db", "err", err, "instance", instance) 462 487 return err ··· 486 511 if err != nil || len(spindles) != 1 { 487 512 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 488 513 } 514 + spindle := spindles[0] 489 515 490 516 tx, err := ddb.Begin() 491 517 if err != nil { ··· 496 522 i.Enforcer.E.LoadPolicy() 497 523 }() 498 524 499 - err = db.DeleteSpindle( 525 + // remove spindle members first 526 + err = db.RemoveSpindleMember( 500 527 tx, 501 528 db.FilterEq("owner", did), 502 529 db.FilterEq("instance", instance), ··· 505 532 return err 506 533 } 507 534 508 - err = i.Enforcer.RemoveSpindle(instance) 535 + err = db.DeleteSpindle( 536 + tx, 537 + db.FilterEq("owner", did), 538 + db.FilterEq("instance", instance), 539 + ) 509 540 if err != nil { 510 541 return err 542 + } 543 + 544 + if spindle.Verified != nil { 545 + err = i.Enforcer.RemoveSpindle(instance) 546 + if err != nil { 547 + return err 548 + } 511 549 } 512 550 513 551 err = tx.Commit() ··· 523 561 524 562 return nil 525 563 } 564 + 565 + func (i *Ingester) ingestString(e *models.Event) error { 566 + did := e.Did 567 + rkey := e.Commit.RKey 568 + 569 + var err error 570 + 571 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 572 + l.Info("ingesting record") 573 + 574 + ddb, ok := i.Db.Execer.(*db.DB) 575 + if !ok { 576 + return fmt.Errorf("failed to index string record, invalid db cast") 577 + } 578 + 579 + switch e.Commit.Operation { 580 + case models.CommitOperationCreate, models.CommitOperationUpdate: 581 + raw := json.RawMessage(e.Commit.Record) 582 + record := tangled.String{} 583 + err = json.Unmarshal(raw, &record) 584 + if err != nil { 585 + l.Error("invalid record", "err", err) 586 + return err 587 + } 588 + 589 + string := db.StringFromRecord(did, rkey, record) 590 + 591 + if err = string.Validate(); err != nil { 592 + l.Error("invalid record", "err", err) 593 + return err 594 + } 595 + 596 + if err = db.AddString(ddb, string); err != nil { 597 + l.Error("failed to add string", "err", err) 598 + return err 599 + } 600 + 601 + return nil 602 + 603 + case models.CommitOperationDelete: 604 + if err := db.DeleteString( 605 + ddb, 606 + db.FilterEq("did", did), 607 + db.FilterEq("rkey", rkey), 608 + ); err != nil { 609 + l.Error("failed to delete", "err", err) 610 + return fmt.Errorf("failed to delete string record: %w", err) 611 + } 612 + 613 + return nil 614 + } 615 + 616 + return nil 617 + } 618 + 619 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 620 + did := e.Did 621 + rkey := e.Commit.RKey 622 + 623 + var err error 624 + 625 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 626 + l.Info("ingesting record") 627 + 628 + ddb, ok := i.Db.Execer.(*db.DB) 629 + if !ok { 630 + return fmt.Errorf("failed to index issue record, invalid db cast") 631 + } 632 + 633 + switch e.Commit.Operation { 634 + case models.CommitOperationCreate: 635 + raw := json.RawMessage(e.Commit.Record) 636 + record := tangled.RepoIssue{} 637 + err = json.Unmarshal(raw, &record) 638 + if err != nil { 639 + l.Error("invalid record", "err", err) 640 + return err 641 + } 642 + 643 + issue := db.IssueFromRecord(did, rkey, record) 644 + 645 + sanitizer := markup.NewSanitizer() 646 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 647 + return fmt.Errorf("title is empty after HTML sanitization") 648 + } 649 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 650 + return fmt.Errorf("body is empty after HTML sanitization") 651 + } 652 + 653 + tx, err := ddb.BeginTx(ctx, nil) 654 + if err != nil { 655 + l.Error("failed to begin transaction", "err", err) 656 + return err 657 + } 658 + 659 + err = db.NewIssue(tx, &issue) 660 + if err != nil { 661 + l.Error("failed to create issue", "err", err) 662 + return err 663 + } 664 + 665 + return nil 666 + 667 + case models.CommitOperationUpdate: 668 + raw := json.RawMessage(e.Commit.Record) 669 + record := tangled.RepoIssue{} 670 + err = json.Unmarshal(raw, &record) 671 + if err != nil { 672 + l.Error("invalid record", "err", err) 673 + return err 674 + } 675 + 676 + body := "" 677 + if record.Body != nil { 678 + body = *record.Body 679 + } 680 + 681 + sanitizer := markup.NewSanitizer() 682 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 683 + return fmt.Errorf("title is empty after HTML sanitization") 684 + } 685 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 686 + return fmt.Errorf("body is empty after HTML sanitization") 687 + } 688 + 689 + err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 690 + if err != nil { 691 + l.Error("failed to update issue", "err", err) 692 + return err 693 + } 694 + 695 + return nil 696 + 697 + case models.CommitOperationDelete: 698 + if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 699 + l.Error("failed to delete", "err", err) 700 + return fmt.Errorf("failed to delete issue record: %w", err) 701 + } 702 + 703 + return nil 704 + } 705 + 706 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 707 + } 708 + 709 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 710 + did := e.Did 711 + rkey := e.Commit.RKey 712 + 713 + var err error 714 + 715 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 716 + l.Info("ingesting record") 717 + 718 + ddb, ok := i.Db.Execer.(*db.DB) 719 + if !ok { 720 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 721 + } 722 + 723 + switch e.Commit.Operation { 724 + case models.CommitOperationCreate: 725 + raw := json.RawMessage(e.Commit.Record) 726 + record := tangled.RepoIssueComment{} 727 + err = json.Unmarshal(raw, &record) 728 + if err != nil { 729 + l.Error("invalid record", "err", err) 730 + return err 731 + } 732 + 733 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 734 + if err != nil { 735 + l.Error("failed to parse comment from record", "err", err) 736 + return err 737 + } 738 + 739 + sanitizer := markup.NewSanitizer() 740 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 741 + return fmt.Errorf("body is empty after HTML sanitization") 742 + } 743 + 744 + err = db.NewIssueComment(ddb, &comment) 745 + if err != nil { 746 + l.Error("failed to create issue comment", "err", err) 747 + return err 748 + } 749 + 750 + return nil 751 + 752 + case models.CommitOperationUpdate: 753 + raw := json.RawMessage(e.Commit.Record) 754 + record := tangled.RepoIssueComment{} 755 + err = json.Unmarshal(raw, &record) 756 + if err != nil { 757 + l.Error("invalid record", "err", err) 758 + return err 759 + } 760 + 761 + sanitizer := markup.NewSanitizer() 762 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 763 + return fmt.Errorf("body is empty after HTML sanitization") 764 + } 765 + 766 + err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 767 + if err != nil { 768 + l.Error("failed to update issue comment", "err", err) 769 + return err 770 + } 771 + 772 + return nil 773 + 774 + case models.CommitOperationDelete: 775 + if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 776 + l.Error("failed to delete", "err", err) 777 + return fmt.Errorf("failed to delete issue comment record: %w", err) 778 + } 779 + 780 + return nil 781 + } 782 + 783 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 784 + }
+66 -119
appview/issues/issues.go
··· 7 7 "net/http" 8 8 "slices" 9 9 "strconv" 10 + "strings" 10 11 "time" 11 12 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 14 "github.com/bluesky-social/indigo/atproto/data" 14 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" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 25 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/idresolver" 28 + "tangled.sh/tangled.sh/core/tid" 27 29 ) 28 30 29 31 type Issues struct { ··· 33 35 idResolver *idresolver.Resolver 34 36 db *db.DB 35 37 config *config.Config 36 - posthog posthog.Client 38 + notifier notify.Notifier 37 39 } 38 40 39 41 func New( ··· 43 45 idResolver *idresolver.Resolver, 44 46 db *db.DB, 45 47 config *config.Config, 46 - posthog posthog.Client, 48 + notifier notify.Notifier, 47 49 ) *Issues { 48 50 return &Issues{ 49 51 oauth: oauth, ··· 52 54 idResolver: idResolver, 53 55 db: db, 54 56 config: config, 55 - posthog: posthog, 57 + notifier: notifier, 56 58 } 57 59 } 58 60 ··· 72 74 return 73 75 } 74 76 75 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 77 + issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 76 78 if err != nil { 77 79 log.Println("failed to get issue and comments", err) 78 80 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 81 return 80 82 } 81 83 82 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 84 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 83 85 if err != nil { 84 - log.Println("failed to resolve issue owner", err) 86 + log.Println("failed to get issue reactions") 87 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 85 88 } 86 89 87 - identsToResolve := make([]string, len(comments)) 88 - for i, comment := range comments { 89 - identsToResolve[i] = comment.OwnerDid 90 + userReactions := map[db.ReactionKind]bool{} 91 + if user != nil { 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 90 93 } 91 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 92 - didHandleMap := make(map[string]string) 93 - for _, identity := range resolvedIds { 94 - if !identity.Handle.IsInvalidHandle() { 95 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 96 - } else { 97 - didHandleMap[identity.DID.String()] = identity.DID.String() 98 - } 94 + 95 + issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 96 + if err != nil { 97 + log.Println("failed to resolve issue owner", err) 99 98 } 100 99 101 100 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 102 101 LoggedInUser: user, 103 102 RepoInfo: f.RepoInfo(user), 104 - Issue: *issue, 103 + Issue: issue, 105 104 Comments: comments, 106 105 107 106 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 - DidHandleMap: didHandleMap, 107 + 108 + OrderedReactionKinds: db.OrderedReactionKinds, 109 + Reactions: reactionCountMap, 110 + UserReacted: userReactions, 109 111 }) 110 112 111 113 } ··· 126 128 return 127 129 } 128 130 129 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 131 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 130 132 if err != nil { 131 133 log.Println("failed to get issue", err) 132 134 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 155 157 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 158 Collection: tangled.RepoIssueStateNSID, 157 159 Repo: user.Did, 158 - Rkey: appview.TID(), 160 + Rkey: tid.TID(), 159 161 Record: &lexutil.LexiconTypeDecoder{ 160 162 Val: &tangled.RepoIssueState{ 161 - Issue: issue.IssueAt, 163 + Issue: issue.AtUri().String(), 162 164 State: closed, 163 165 }, 164 166 }, ··· 170 172 return 171 173 } 172 174 173 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 175 + err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 174 176 if err != nil { 175 177 log.Println("failed to close issue", err) 176 178 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 202 204 return 203 205 } 204 206 205 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 207 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 206 208 if err != nil { 207 209 log.Println("failed to get issue", err) 208 210 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 219 221 isIssueOwner := user.Did == issue.OwnerDid 220 222 221 223 if isCollaborator || isIssueOwner { 222 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 224 + err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 223 225 if err != nil { 224 226 log.Println("failed to reopen issue", err) 225 227 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") ··· 259 261 } 260 262 261 263 commentId := mathrand.IntN(1000000) 262 - rkey := appview.TID() 264 + rkey := tid.TID() 263 265 264 266 err := db.NewIssueComment(rp.db, &db.Comment{ 265 267 OwnerDid: user.Did, 266 - RepoAt: f.RepoAt, 268 + RepoAt: f.RepoAt(), 267 269 Issue: issueIdInt, 268 270 CommentId: commentId, 269 271 Body: body, ··· 276 278 } 277 279 278 280 createdAt := time.Now().Format(time.RFC3339) 279 - commentIdInt64 := int64(commentId) 280 281 ownerDid := user.Did 281 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 282 + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 282 283 if err != nil { 283 284 log.Println("failed to get issue at", err) 284 285 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 286 return 286 287 } 287 288 288 - atUri := f.RepoAt.String() 289 + atUri := f.RepoAt().String() 289 290 client, err := rp.oauth.AuthorizedClient(r) 290 291 if err != nil { 291 292 log.Println("failed to get authorized client", err) ··· 300 301 Val: &tangled.RepoIssueComment{ 301 302 Repo: &atUri, 302 303 Issue: issueAt, 303 - CommentId: &commentIdInt64, 304 304 Owner: &ownerDid, 305 305 Body: body, 306 306 CreatedAt: createdAt, ··· 342 342 return 343 343 } 344 344 345 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 345 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 346 346 if err != nil { 347 347 log.Println("failed to get issue", err) 348 348 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 349 349 return 350 350 } 351 351 352 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 352 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 353 353 if err != nil { 354 354 http.Error(w, "bad comment id", http.StatusBadRequest) 355 355 return 356 356 } 357 357 358 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 359 - if err != nil { 360 - log.Println("failed to resolve did") 361 - return 362 - } 363 - 364 - didHandleMap := make(map[string]string) 365 - if !identity.Handle.IsInvalidHandle() { 366 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 367 - } else { 368 - didHandleMap[identity.DID.String()] = identity.DID.String() 369 - } 370 - 371 358 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 372 359 LoggedInUser: user, 373 360 RepoInfo: f.RepoInfo(user), 374 - DidHandleMap: didHandleMap, 375 361 Issue: issue, 376 362 Comment: comment, 377 363 }) ··· 401 387 return 402 388 } 403 389 404 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 390 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 405 391 if err != nil { 406 392 log.Println("failed to get issue", err) 407 393 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 408 394 return 409 395 } 410 396 411 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 397 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 412 398 if err != nil { 413 399 http.Error(w, "bad comment id", http.StatusBadRequest) 414 400 return ··· 463 449 repoAt := record["repo"].(string) 464 450 issueAt := record["issue"].(string) 465 451 createdAt := record["createdAt"].(string) 466 - commentIdInt64 := int64(commentIdInt) 467 452 468 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 469 454 Collection: tangled.RepoIssueCommentNSID, ··· 474 459 Val: &tangled.RepoIssueComment{ 475 460 Repo: &repoAt, 476 461 Issue: issueAt, 477 - CommentId: &commentIdInt64, 478 462 Owner: &comment.OwnerDid, 479 463 Body: newBody, 480 464 CreatedAt: createdAt, ··· 487 471 } 488 472 489 473 // optimistic update for htmx 490 - didHandleMap := map[string]string{ 491 - user.Did: user.Handle, 492 - } 493 474 comment.Body = newBody 494 475 comment.Edited = &edited 495 476 ··· 497 478 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 498 479 LoggedInUser: user, 499 480 RepoInfo: f.RepoInfo(user), 500 - DidHandleMap: didHandleMap, 501 481 Issue: issue, 502 482 Comment: comment, 503 483 }) ··· 523 503 return 524 504 } 525 505 526 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 506 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 527 507 if err != nil { 528 508 log.Println("failed to get issue", err) 529 509 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 538 518 return 539 519 } 540 520 541 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 521 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 542 522 if err != nil { 543 523 http.Error(w, "bad comment id", http.StatusBadRequest) 544 524 return ··· 556 536 557 537 // optimistic deletion 558 538 deleted := time.Now() 559 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 539 + err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 560 540 if err != nil { 561 541 log.Println("failed to delete comment") 562 542 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 582 562 } 583 563 584 564 // optimistic update for htmx 585 - didHandleMap := map[string]string{ 586 - user.Did: user.Handle, 587 - } 588 565 comment.Body = "" 589 566 comment.Deleted = &deleted 590 567 ··· 592 569 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 593 570 LoggedInUser: user, 594 571 RepoInfo: f.RepoInfo(user), 595 - DidHandleMap: didHandleMap, 596 572 Issue: issue, 597 573 Comment: comment, 598 574 }) 599 - return 600 575 } 601 576 602 577 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 625 600 return 626 601 } 627 602 628 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 603 + issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 629 604 if err != nil { 630 605 log.Println("failed to get issues", err) 631 606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 632 607 return 633 608 } 634 609 635 - identsToResolve := make([]string, len(issues)) 636 - for i, issue := range issues { 637 - identsToResolve[i] = issue.OwnerDid 638 - } 639 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 640 - didHandleMap := make(map[string]string) 641 - for _, identity := range resolvedIds { 642 - if !identity.Handle.IsInvalidHandle() { 643 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 644 - } else { 645 - didHandleMap[identity.DID.String()] = identity.DID.String() 646 - } 647 - } 648 - 649 610 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 650 611 LoggedInUser: rp.oauth.GetUser(r), 651 612 RepoInfo: f.RepoInfo(user), 652 613 Issues: issues, 653 - DidHandleMap: didHandleMap, 654 614 FilteringByOpen: isOpen, 655 615 Page: page, 656 616 }) 657 - return 658 617 } 659 618 660 619 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { ··· 681 640 return 682 641 } 683 642 643 + sanitizer := markup.NewSanitizer() 644 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 + rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 646 + return 647 + } 648 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 + rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 650 + return 651 + } 652 + 684 653 tx, err := rp.db.BeginTx(r.Context(), nil) 685 654 if err != nil { 686 655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 687 656 return 688 657 } 689 658 690 - err = db.NewIssue(tx, &db.Issue{ 691 - RepoAt: f.RepoAt, 659 + issue := &db.Issue{ 660 + RepoAt: f.RepoAt(), 661 + Rkey: tid.TID(), 692 662 Title: title, 693 663 Body: body, 694 664 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 665 } 701 - 702 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 666 + err = db.NewIssue(tx, issue) 703 667 if err != nil { 704 - log.Println("failed to get issue id", err) 668 + log.Println("failed to create issue", err) 705 669 rp.pages.Notice(w, "issues", "Failed to create issue.") 706 670 return 707 671 } ··· 712 676 rp.pages.Notice(w, "issues", "Failed to create issue.") 713 677 return 714 678 } 715 - atUri := f.RepoAt.String() 716 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 679 + atUri := f.RepoAt().String() 680 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 717 681 Collection: tangled.RepoIssueNSID, 718 682 Repo: user.Did, 719 - Rkey: appview.TID(), 683 + Rkey: issue.Rkey, 720 684 Record: &lexutil.LexiconTypeDecoder{ 721 685 Val: &tangled.RepoIssue{ 722 - Repo: atUri, 723 - Title: title, 724 - Body: &body, 725 - Owner: user.Did, 726 - IssueId: int64(issueId), 686 + Repo: atUri, 687 + Title: title, 688 + Body: &body, 689 + Owner: user.Did, 727 690 }, 728 691 }, 729 692 }) ··· 733 696 return 734 697 } 735 698 736 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 737 - if err != nil { 738 - log.Println("failed to set issue at", err) 739 - rp.pages.Notice(w, "issues", "Failed to create issue.") 740 - return 741 - } 699 + rp.notifier.NewIssue(r.Context(), issue) 742 700 743 - if !rp.config.Core.Dev { 744 - err = rp.posthog.Enqueue(posthog.Capture{ 745 - DistinctId: user.Did, 746 - Event: "new_issue", 747 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 748 - }) 749 - if err != nil { 750 - log.Println("failed to enqueue posthog event:", err) 751 - } 752 - } 753 - 754 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 701 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 755 702 return 756 703 } 757 704 }
+478
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 + k.Pages.Knot(w, pages.KnotParams{ 338 + LoggedInUser: user, 339 + Registration: reg, 340 + Members: members, 341 + Repos: repoByMember, 342 + IsOwner: true, 343 + }) 344 + } 345 + 346 + // list members of domain, requires auth and requires owner status 347 + func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 348 + l := k.Logger.With("handler", "members") 349 + 350 + domain := chi.URLParam(r, "domain") 351 + if domain == "" { 352 + http.Error(w, "malformed url", http.StatusBadRequest) 353 + return 354 + } 355 + l = l.With("domain", domain) 356 + 357 + // list all members for this domain 358 + memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 359 + if err != nil { 360 + w.Write([]byte("failed to fetch member list")) 361 + return 362 + } 363 + 364 + w.Write([]byte(strings.Join(memberDids, "\n"))) 365 + } 366 + 367 + // add member to domain, requires auth and requires invite access 368 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 369 + l := k.Logger.With("handler", "members") 370 + 371 + domain := chi.URLParam(r, "domain") 372 + if domain == "" { 373 + http.Error(w, "malformed url", http.StatusBadRequest) 374 + return 375 + } 376 + l = l.With("domain", domain) 377 + 378 + reg, err := db.RegistrationByDomain(k.Db, domain) 379 + if err != nil { 380 + l.Error("failed to get registration by domain", "err", err) 381 + http.Error(w, "malformed url", http.StatusBadRequest) 382 + return 383 + } 384 + 385 + noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 386 + l = l.With("notice-id", noticeId) 387 + defaultErr := "Failed to add member. Try again later." 388 + fail := func() { 389 + k.Pages.Notice(w, noticeId, defaultErr) 390 + } 391 + 392 + subjectIdentifier := r.FormValue("subject") 393 + if subjectIdentifier == "" { 394 + http.Error(w, "malformed form", http.StatusBadRequest) 395 + return 396 + } 397 + l = l.With("subjectIdentifier", subjectIdentifier) 398 + 399 + subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 400 + if err != nil { 401 + l.Error("failed to resolve identity", "err", err) 402 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 403 + return 404 + } 405 + l = l.With("subjectDid", subjectIdentity.DID) 406 + 407 + l.Info("adding member to knot") 408 + 409 + // announce this relation into the firehose, store into owners' pds 410 + client, err := k.OAuth.AuthorizedClient(r) 411 + if err != nil { 412 + l.Error("failed to create client", "err", err) 413 + fail() 414 + return 415 + } 416 + 417 + currentUser := k.OAuth.GetUser(r) 418 + createdAt := time.Now().Format(time.RFC3339) 419 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 420 + Collection: tangled.KnotMemberNSID, 421 + Repo: currentUser.Did, 422 + Rkey: tid.TID(), 423 + Record: &lexutil.LexiconTypeDecoder{ 424 + Val: &tangled.KnotMember{ 425 + Subject: subjectIdentity.DID.String(), 426 + Domain: domain, 427 + CreatedAt: createdAt, 428 + }}, 429 + }) 430 + // invalid record 431 + if err != nil { 432 + l.Error("failed to write to PDS", "err", err) 433 + fail() 434 + return 435 + } 436 + l = l.With("at-uri", resp.Uri) 437 + l.Info("wrote record to PDS") 438 + 439 + secret, err := db.GetRegistrationKey(k.Db, domain) 440 + if err != nil { 441 + l.Error("failed to get registration key", "err", err) 442 + fail() 443 + return 444 + } 445 + 446 + ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 447 + if err != nil { 448 + l.Error("failed to create client", "err", err) 449 + fail() 450 + return 451 + } 452 + 453 + ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 454 + if err != nil { 455 + l.Error("failed to reach knotserver", "err", err) 456 + k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 457 + return 458 + } 459 + 460 + if ksResp.StatusCode != http.StatusNoContent { 461 + l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 462 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 463 + return 464 + } 465 + 466 + err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 467 + if err != nil { 468 + l.Error("failed to add member to enforcer", "err", err) 469 + fail() 470 + return 471 + } 472 + 473 + // success 474 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 475 + } 476 + 477 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 478 + }
+17 -22
appview/middleware/middleware.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "net/url" 8 9 "slices" 9 10 "strconv" 10 11 "strings" 11 - "time" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 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 ··· 46 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + returnURL := "/" 50 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 51 + returnURL = u.RequestURI() 52 + } 53 + 54 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 55 + 49 56 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 50 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 57 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 51 58 } 52 59 if r.Header.Get("HX-Request") == "true" { 53 60 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 54 - w.Header().Set("HX-Redirect", "/login") 61 + w.Header().Set("HX-Redirect", loginURL) 55 62 w.WriteHeader(http.StatusOK) 56 63 } 57 64 } ··· 167 174 } 168 175 } 169 176 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 177 func (mw Middleware) ResolveIdent() middlewareFunc { 181 178 excluded := []string{"favicon.ico"} 182 179 ··· 188 185 return 189 186 } 190 187 188 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 + 191 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 191 if err != nil { 193 192 // invalid did or handle 194 - log.Println("failed to resolve did/handle:", err) 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 195 194 mw.pages.Error404(w) 196 195 return 197 196 } ··· 222 221 return 223 222 } 224 223 225 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 230 225 next.ServeHTTP(w, req.WithContext(ctx)) 231 226 }) 232 227 } ··· 251 246 return 252 247 } 253 248 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 250 if err != nil { 256 251 log.Println("failed to get pull and comments", err) 257 252 return ··· 292 287 return 293 288 } 294 289 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 290 + fullName := f.OwnerHandle() + "/" + f.Name 296 291 297 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 293 if r.URL.Query().Get("go-get") == "1" {
+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) {}
+159 -3
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 9 11 "strings" 12 + "time" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/gorilla/sessions" 13 16 "github.com/lestrrat-go/jwx/v2/jwk" 14 17 "github.com/posthog/posthog-go" 15 18 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 20 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 21 "tangled.sh/tangled.sh/core/appview/config" 18 22 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 23 "tangled.sh/tangled.sh/core/appview/middleware" 21 24 "tangled.sh/tangled.sh/core/appview/oauth" 22 25 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 26 "tangled.sh/tangled.sh/core/appview/pages" 27 + "tangled.sh/tangled.sh/core/idresolver" 24 28 "tangled.sh/tangled.sh/core/knotclient" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 104 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 105 110 switch r.Method { 106 111 case http.MethodGet: 107 - o.pages.Login(w, pages.LoginParams{}) 112 + returnURL := r.URL.Query().Get("return_url") 113 + o.pages.Login(w, pages.LoginParams{ 114 + ReturnUrl: returnURL, 115 + }) 108 116 case http.MethodPost: 109 117 handle := r.FormValue("handle") 110 118 ··· 189 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 190 198 DpopPrivateJwk: string(dpopKeyJson), 191 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 192 201 }) 193 202 if err != nil { 194 203 log.Println("failed to save oauth request:", err) ··· 244 253 return 245 254 } 246 255 256 + if iss != oauthRequest.AuthserverIss { 257 + log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 258 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 259 + return 260 + } 261 + 247 262 self := o.oauth.ClientMetadata() 248 263 249 264 oauthClient, err := client.NewClient( ··· 294 309 295 310 log.Println("session saved successfully") 296 311 go o.addToDefaultKnot(oauthRequest.Did) 312 + go o.addToDefaultSpindle(oauthRequest.Did) 297 313 298 314 if !o.config.Core.Dev { 299 315 err = o.posthog.Enqueue(posthog.Capture{ ··· 305 321 } 306 322 } 307 323 308 - http.Redirect(w, r, "/", http.StatusFound) 324 + returnUrl := oauthRequest.ReturnUrl 325 + if returnUrl == "" { 326 + returnUrl = "/" 327 + } 328 + 329 + http.Redirect(w, r, returnUrl, http.StatusFound) 309 330 } 310 331 311 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 330 351 return nil, err 331 352 } 332 353 return pubKey, nil 354 + } 355 + 356 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 357 + // use the tangled.sh app password to get an accessJwt 358 + // and create an sh.tangled.spindle.member record with that 359 + 360 + defaultSpindle := "spindle.tangled.sh" 361 + appPassword := o.config.Core.AppPassword 362 + 363 + spindleMembers, err := db.GetSpindleMembers( 364 + o.db, 365 + db.FilterEq("instance", "spindle.tangled.sh"), 366 + db.FilterEq("subject", did), 367 + ) 368 + if err != nil { 369 + log.Printf("failed to get spindle members for did %s: %v", did, err) 370 + return 371 + } 372 + 373 + if len(spindleMembers) != 0 { 374 + log.Printf("did %s is already a member of the default spindle", did) 375 + return 376 + } 377 + 378 + // TODO: hardcoded tangled handle and did for now 379 + tangledHandle := "tangled.sh" 380 + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 381 + 382 + if appPassword == "" { 383 + log.Println("no app password configured, skipping spindle member addition") 384 + return 385 + } 386 + 387 + log.Printf("adding %s to default spindle", did) 388 + 389 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 390 + if err != nil { 391 + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 392 + return 393 + } 394 + 395 + pdsEndpoint := resolved.PDSEndpoint() 396 + if pdsEndpoint == "" { 397 + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 398 + return 399 + } 400 + 401 + sessionPayload := map[string]string{ 402 + "identifier": tangledHandle, 403 + "password": appPassword, 404 + } 405 + sessionBytes, err := json.Marshal(sessionPayload) 406 + if err != nil { 407 + log.Printf("failed to marshal session payload: %v", err) 408 + return 409 + } 410 + 411 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 412 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 413 + if err != nil { 414 + log.Printf("failed to create session request: %v", err) 415 + return 416 + } 417 + sessionReq.Header.Set("Content-Type", "application/json") 418 + 419 + client := &http.Client{Timeout: 30 * time.Second} 420 + sessionResp, err := client.Do(sessionReq) 421 + if err != nil { 422 + log.Printf("failed to create session: %v", err) 423 + return 424 + } 425 + defer sessionResp.Body.Close() 426 + 427 + if sessionResp.StatusCode != http.StatusOK { 428 + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 429 + return 430 + } 431 + 432 + var session struct { 433 + AccessJwt string `json:"accessJwt"` 434 + } 435 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 436 + log.Printf("failed to decode session response: %v", err) 437 + return 438 + } 439 + 440 + record := tangled.SpindleMember{ 441 + LexiconTypeID: "sh.tangled.spindle.member", 442 + Subject: did, 443 + Instance: defaultSpindle, 444 + CreatedAt: time.Now().Format(time.RFC3339), 445 + } 446 + 447 + recordBytes, err := json.Marshal(record) 448 + if err != nil { 449 + log.Printf("failed to marshal spindle member record: %v", err) 450 + return 451 + } 452 + 453 + payload := map[string]interface{}{ 454 + "repo": tangledDid, 455 + "collection": tangled.SpindleMemberNSID, 456 + "rkey": tid.TID(), 457 + "record": json.RawMessage(recordBytes), 458 + } 459 + 460 + payloadBytes, err := json.Marshal(payload) 461 + if err != nil { 462 + log.Printf("failed to marshal request payload: %v", err) 463 + return 464 + } 465 + 466 + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 467 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 468 + if err != nil { 469 + log.Printf("failed to create HTTP request: %v", err) 470 + return 471 + } 472 + 473 + req.Header.Set("Content-Type", "application/json") 474 + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 475 + 476 + resp, err := client.Do(req) 477 + if err != nil { 478 + log.Printf("failed to add user to default spindle: %v", err) 479 + return 480 + } 481 + defer resp.Body.Close() 482 + 483 + if resp.StatusCode != http.StatusOK { 484 + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 485 + return 486 + } 487 + 488 + log.Printf("successfully added %s to default spindle", did) 333 489 } 334 490 335 491 func (o *OAuthHandler) addToDefaultKnot(did string) {
+85 -2
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" ··· 102 103 if err != nil { 103 104 return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 104 105 } 105 - if expiry.Sub(time.Now()) <= 5*time.Minute { 106 + if time.Until(expiry) <= 5*time.Minute { 106 107 privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 107 108 if err != nil { 108 109 return nil, false, err ··· 206 207 return xrpcClient, nil 207 208 } 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 + 228 + // Specify the Duration in seconds for the expiry of this token 229 + // 230 + // The time of expiry is calculated as time.Now().Unix() + exp 231 + func WithExp(exp int64) ServiceClientOpt { 232 + return func(s *ServiceClientOpts) { 233 + s.exp = time.Now().Unix() + exp 234 + } 235 + } 236 + 237 + func WithLxm(lxm string) ServiceClientOpt { 238 + return func(s *ServiceClientOpts) { 239 + s.lxm = lxm 240 + } 241 + } 242 + 243 + func WithDev(dev bool) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.dev = dev 246 + } 247 + } 248 + 249 + func (s *ServiceClientOpts) Audience() string { 250 + return fmt.Sprintf("did:web:%s", s.service) 251 + } 252 + 253 + func (s *ServiceClientOpts) Host() string { 254 + scheme := "https://" 255 + if s.dev { 256 + scheme = "http://" 257 + } 258 + 259 + return scheme + s.service 260 + } 261 + 262 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 + opts := ServiceClientOpts{} 264 + for _, o := range os { 265 + o(&opts) 266 + } 267 + 268 + authorizedClient, err := o.AuthorizedClient(r) 269 + if err != nil { 270 + return nil, err 271 + } 272 + 273 + // force expiry to atleast 60 seconds in the future 274 + sixty := time.Now().Unix() + 60 275 + if opts.exp < sixty { 276 + opts.exp = sixty 277 + } 278 + 279 + resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 + if err != nil { 281 + return nil, err 282 + } 283 + 284 + return &indigo_xrpc.Client{ 285 + Auth: &indigo_xrpc.AuthInfo{ 286 + AccessJwt: resp.Token, 287 + }, 288 + Host: opts.Host(), 289 + }, nil 290 + } 291 + 209 292 type ClientMetadata struct { 210 293 ClientID string `json:"client_id"` 211 294 ClientName string `json:"client_name"` ··· 232 315 redirectURIs := makeRedirectURIs(clientURI) 233 316 234 317 if o.config.Core.Dev { 235 - clientURI = fmt.Sprintf("http://127.0.0.1:3000") 318 + clientURI = "http://127.0.0.1:3000" 236 319 redirectURIs = makeRedirectURIs(clientURI) 237 320 238 321 query := url.Values{}
+96 -37
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "context" 4 5 "crypto/hmac" 5 6 "crypto/sha256" 6 7 "encoding/hex" ··· 17 18 "time" 18 19 19 20 "github.com/dustin/go-humanize" 20 - "github.com/microcosm-cc/bluemonday" 21 + "github.com/go-enry/go-enry/v2" 21 22 "tangled.sh/tangled.sh/core/appview/filetree" 22 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 24 ) ··· 26 27 return template.FuncMap{ 27 28 "split": func(s string) []string { 28 29 return strings.Split(s, "\n") 30 + }, 31 + "resolve": func(s string) string { 32 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 33 + 34 + if err != nil { 35 + return s 36 + } 37 + 38 + if identity.Handle.IsInvalidHandle() { 39 + return "handle.invalid" 40 + } 41 + 42 + return "@" + identity.Handle.String() 29 43 }, 30 44 "truncateAt30": func(s string) string { 31 45 if len(s) <= 30 { ··· 73 87 "negf64": func(a float64) float64 { 74 88 return -a 75 89 }, 76 - "cond": func(cond interface{}, a, b string) string { 90 + "cond": func(cond any, a, b string) string { 77 91 if cond == nil { 78 92 return b 79 93 } ··· 105 119 s = append(s, values...) 106 120 return s 107 121 }, 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 { 122 + "commaFmt": humanize.Comma, 123 + "relTimeFmt": humanize.Time, 124 + "shortRelTimeFmt": func(t time.Time) string { 114 125 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 115 126 {time.Second, "now", time.Second}, 116 127 {2 * time.Second, "1s %s", 1}, ··· 129 140 {math.MaxInt64, "a long while %s", 1}, 130 141 }) 131 142 }, 132 - "durationFmt": func(duration time.Duration) string { 143 + "longTimeFmt": func(t time.Time) string { 144 + return t.Format("Jan 2, 2006, 3:04 PM MST") 145 + }, 146 + "iso8601DateTimeFmt": func(t time.Time) string { 147 + return t.Format("2006-01-02T15:04:05-07:00") 148 + }, 149 + "iso8601DurationFmt": func(duration time.Duration) string { 133 150 days := int64(duration.Hours() / 24) 134 151 hours := int64(math.Mod(duration.Hours(), 24)) 135 152 minutes := int64(math.Mod(duration.Minutes(), 60)) 136 153 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, " ") 154 + return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds) 155 + }, 156 + "durationFmt": func(duration time.Duration) string { 157 + return durationFmt(duration, [4]string{"d", "hr", "min", "s"}) 158 + }, 159 + "longDurationFmt": func(duration time.Duration) string { 160 + return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"}) 157 161 }, 158 162 "byteFmt": humanize.Bytes, 159 163 "length": func(slice any) int { ··· 176 180 return html.UnescapeString(s) 177 181 }, 178 182 "nl2br": func(text string) template.HTML { 179 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 183 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 180 184 }, 181 185 "unwrapText": func(text string) string { 182 186 paragraphs := strings.Split(text, "\n\n") ··· 200 204 if v.Len() == 0 { 201 205 return nil 202 206 } 203 - return v.Slice(0, min(n, v.Len()-1)).Interface() 207 + return v.Slice(0, min(n, v.Len())).Interface() 204 208 }, 205 - 206 209 "markdown": func(text string) template.HTML { 207 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 208 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 210 + p.rctx.RendererType = markup.RendererTypeDefault 211 + htmlString := p.rctx.RenderMarkdown(text) 212 + sanitized := p.rctx.SanitizeDefault(htmlString) 213 + return template.HTML(sanitized) 214 + }, 215 + "description": func(text string) template.HTML { 216 + p.rctx.RendererType = markup.RendererTypeDefault 217 + htmlString := p.rctx.RenderMarkdown(text) 218 + sanitized := p.rctx.SanitizeDescription(htmlString) 219 + return template.HTML(sanitized) 209 220 }, 210 221 "isNil": func(t any) bool { 211 222 // returns false for other "zero" values ··· 245 256 }, 246 257 "cssContentHash": CssContentHash, 247 258 "fileTree": filetree.FileTree, 259 + "pathEscape": func(s string) string { 260 + return url.PathEscape(s) 261 + }, 248 262 "pathUnescape": func(s string) string { 249 263 u, _ := url.PathUnescape(s) 250 264 return u 251 265 }, 252 266 253 - "tinyAvatar": p.tinyAvatar, 267 + "tinyAvatar": func(handle string) string { 268 + return p.avatarUri(handle, "tiny") 269 + }, 270 + "fullAvatar": func(handle string) string { 271 + return p.avatarUri(handle, "") 272 + }, 273 + "langColor": enry.GetColor, 274 + "layoutSide": func() string { 275 + return "col-span-1 md:col-span-2 lg:col-span-3" 276 + }, 277 + "layoutCenter": func() string { 278 + return "col-span-1 md:col-span-8 lg:col-span-6" 279 + }, 254 280 } 255 281 } 256 282 257 - func (p *Pages) tinyAvatar(handle string) string { 283 + func (p *Pages) avatarUri(handle, size string) string { 258 284 handle = strings.TrimPrefix(handle, "@") 285 + 259 286 secret := p.avatar.SharedSecret 260 287 h := hmac.New(sha256.New, []byte(secret)) 261 288 h.Write([]byte(handle)) 262 289 signature := hex.EncodeToString(h.Sum(nil)) 263 - return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle) 290 + 291 + sizeArg := "" 292 + if size != "" { 293 + sizeArg = fmt.Sprintf("size=%s", size) 294 + } 295 + return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 264 296 } 265 297 266 298 func icon(name string, classes []string) (template.HTML, error) { ··· 288 320 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 289 321 return template.HTML(modifiedSVG), nil 290 322 } 323 + 324 + func durationFmt(duration time.Duration, names [4]string) string { 325 + days := int64(duration.Hours() / 24) 326 + hours := int64(math.Mod(duration.Hours(), 24)) 327 + minutes := int64(math.Mod(duration.Minutes(), 60)) 328 + seconds := int64(math.Mod(duration.Seconds(), 60)) 329 + 330 + chunks := []struct { 331 + name string 332 + amount int64 333 + }{ 334 + {names[0], days}, 335 + {names[1], hours}, 336 + {names[2], minutes}, 337 + {names[3], seconds}, 338 + } 339 + 340 + parts := []string{} 341 + 342 + for _, chunk := range chunks { 343 + if chunk.amount != 0 { 344 + parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 345 + } 346 + } 347 + 348 + return strings.Join(parts, " ") 349 + }
+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
+61 -31
appview/pages/markup/markdown.go
··· 9 9 "path" 10 10 "strings" 11 11 12 - "github.com/microcosm-cc/bluemonday" 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 13 14 "github.com/yuin/goldmark" 15 + highlighting "github.com/yuin/goldmark-highlighting/v2" 14 16 "github.com/yuin/goldmark/ast" 15 17 "github.com/yuin/goldmark/extension" 16 18 "github.com/yuin/goldmark/parser" ··· 40 42 repoinfo.RepoInfo 41 43 IsDev bool 42 44 RendererType RendererType 45 + Sanitizer Sanitizer 43 46 } 44 47 45 48 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 49 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 50 + goldmark.WithExtensions( 51 + extension.GFM, 52 + highlighting.NewHighlighting( 53 + highlighting.WithFormatOptions( 54 + chromahtml.Standalone(false), 55 + chromahtml.WithClasses(true), 56 + ), 57 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 58 + ), 59 + extension.NewFootnote( 60 + extension.WithFootnoteIDPrefix([]byte("footnote")), 61 + ), 62 + ), 48 63 goldmark.WithParserOptions( 49 64 parser.WithAutoHeadingID(), 50 65 ), ··· 145 160 } 146 161 } 147 162 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 150 - 151 - // video 152 - policy.AllowElements("video") 153 - policy.AllowAttrs("controls").OnElements("video") 154 - policy.AllowElements("source") 155 - policy.AllowAttrs("src", "type").OnElements("source") 156 - 157 - // centering content 158 - policy.AllowElements("center") 163 + func (rctx *RenderContext) SanitizeDefault(html string) string { 164 + return rctx.Sanitizer.SanitizeDefault(html) 165 + } 159 166 160 - policy.AllowAttrs("align", "style", "width", "height").Globally() 161 - policy.AllowStyles( 162 - "margin", 163 - "padding", 164 - "text-align", 165 - "font-weight", 166 - "text-decoration", 167 - "padding-left", 168 - "padding-right", 169 - "padding-top", 170 - "padding-bottom", 171 - "margin-left", 172 - "margin-right", 173 - "margin-top", 174 - "margin-bottom", 175 - ) 176 - return policy.Sanitize(html) 167 + func (rctx *RenderContext) SanitizeDescription(html string) string { 168 + return rctx.Sanitizer.SanitizeDescription(html) 177 169 } 178 170 179 171 type MarkdownTransformer struct { ··· 189 181 switch a.rctx.RendererType { 190 182 case RendererTypeRepoMarkdown: 191 183 switch n := n.(type) { 184 + case *ast.Heading: 185 + a.rctx.anchorHeadingTransformer(n) 192 186 case *ast.Link: 193 187 a.rctx.relativeLinkTransformer(n) 194 188 case *ast.Image: ··· 197 191 } 198 192 case RendererTypeDefault: 199 193 switch n := n.(type) { 194 + case *ast.Heading: 195 + a.rctx.anchorHeadingTransformer(n) 200 196 case *ast.Image: 201 197 a.rctx.imageFromKnotAstTransformer(n) 202 198 a.rctx.camoImageLinkAstTransformer(n) ··· 211 207 212 208 dst := string(link.Destination) 213 209 214 - if isAbsoluteUrl(dst) { 210 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 211 return 216 212 } 217 213 ··· 252 248 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 249 } 254 250 251 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 252 + idGeneric, exists := h.AttributeString("id") 253 + if !exists { 254 + return // no id, nothing to do 255 + } 256 + id, ok := idGeneric.([]byte) 257 + if !ok { 258 + return 259 + } 260 + 261 + // create anchor link 262 + anchor := ast.NewLink() 263 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 264 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 265 + 266 + // create icon text 267 + iconText := ast.NewString([]byte("#")) 268 + anchor.AppendChild(anchor, iconText) 269 + 270 + // set class on heading 271 + h.SetAttribute([]byte("class"), []byte("heading")) 272 + 273 + // append anchor to heading 274 + h.AppendChild(h, anchor) 275 + } 276 + 255 277 // actualPath decides when to join the file path with the 256 278 // current repository directory (essentially only when the link 257 279 // destination is relative. if it's absolute then we assume the ··· 271 293 } 272 294 return parsed.IsAbs() 273 295 } 296 + 297 + func isFragment(link string) bool { 298 + return strings.HasPrefix(link, "#") 299 + } 300 + 301 + func isMail(link string) bool { 302 + return strings.HasPrefix(link, "mailto:") 303 + }
+117
appview/pages/markup/sanitizer.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + return policy 101 + } 102 + 103 + func descriptionPolicy() *bluemonday.Policy { 104 + policy := bluemonday.NewPolicy() 105 + policy.AllowStandardURLs() 106 + 107 + // allow italics and bold. 108 + policy.AllowElements("i", "b", "em", "strong") 109 + 110 + // allow code. 111 + policy.AllowElements("code") 112 + 113 + // allow links 114 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 115 + 116 + return policy 117 + }
+279 -70
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" ··· 22 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 + "tangled.sh/tangled.sh/core/idresolver" 25 28 "tangled.sh/tangled.sh/core/patchutil" 26 29 "tangled.sh/tangled.sh/core/types" 27 30 ··· 29 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 30 33 "github.com/alecthomas/chroma/v2/lexers" 31 34 "github.com/alecthomas/chroma/v2/styles" 35 + "github.com/bluesky-social/indigo/atproto/identity" 32 36 "github.com/bluesky-social/indigo/atproto/syntax" 33 37 "github.com/go-git/go-git/v5/plumbing" 34 38 "github.com/go-git/go-git/v5/plumbing/object" 35 - "github.com/microcosm-cc/bluemonday" 36 39 ) 37 40 38 41 //go:embed templates/* static 39 42 var Files embed.FS 40 43 41 44 type Pages struct { 42 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + t map[string]*template.Template 47 + 43 48 avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 44 50 dev bool 45 51 embedFS embed.FS 46 52 templateDir string // Path to templates on disk for dev mode 47 53 rctx *markup.RenderContext 48 54 } 49 55 50 - func NewPages(config *config.Config) *Pages { 56 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 51 57 // initialized with safe defaults, can be overriden per use 52 58 rctx := &markup.RenderContext{ 53 59 IsDev: config.Core.Dev, 54 60 CamoUrl: config.Camo.Host, 55 61 CamoSecret: config.Camo.SharedSecret, 62 + Sanitizer: markup.NewSanitizer(), 56 63 } 57 64 58 65 p := &Pages{ 66 + mu: sync.RWMutex{}, 59 67 t: make(map[string]*template.Template), 60 68 dev: config.Core.Dev, 61 69 avatar: config.Avatar, 62 70 embedFS: Files, 63 71 rctx: rctx, 72 + resolver: res, 64 73 templateDir: "appview/pages", 65 74 } 66 75 ··· 147 156 } 148 157 149 158 log.Printf("total templates loaded: %d", len(templates)) 159 + p.mu.Lock() 160 + defer p.mu.Unlock() 150 161 p.t = templates 151 162 } 152 163 ··· 207 218 } 208 219 209 220 // Update the template in the map 221 + p.mu.Lock() 222 + defer p.mu.Unlock() 210 223 p.t[name] = tmpl 211 224 log.Printf("template reloaded from disk: %s", name) 212 225 return nil ··· 221 234 } 222 235 } 223 236 237 + p.mu.RLock() 238 + defer p.mu.RUnlock() 224 239 tmpl, exists := p.t[templateName] 225 240 if !exists { 226 241 return fmt.Errorf("template not found: %s", templateName) ··· 245 260 return p.executeOrReload(name, w, "layouts/repobase", params) 246 261 } 247 262 263 + func (p *Pages) Favicon(w io.Writer) error { 264 + return p.executePlain("favicon", w, nil) 265 + } 266 + 248 267 type LoginParams struct { 268 + ReturnUrl string 249 269 } 250 270 251 271 func (p *Pages) Login(w io.Writer, params LoginParams) error { 252 272 return p.executePlain("user/login", w, params) 253 273 } 254 274 275 + func (p *Pages) Signup(w io.Writer) error { 276 + return p.executePlain("user/signup", w, nil) 277 + } 278 + 279 + func (p *Pages) CompleteSignup(w io.Writer) error { 280 + return p.executePlain("user/completeSignup", w, nil) 281 + } 282 + 283 + type TermsOfServiceParams struct { 284 + LoggedInUser *oauth.User 285 + } 286 + 287 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 288 + return p.execute("legal/terms", w, params) 289 + } 290 + 291 + type PrivacyPolicyParams struct { 292 + LoggedInUser *oauth.User 293 + } 294 + 295 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 296 + return p.execute("legal/privacy", w, params) 297 + } 298 + 255 299 type TimelineParams struct { 256 300 LoggedInUser *oauth.User 257 301 Timeline []db.TimelineEvent 258 - DidHandleMap map[string]string 302 + Repos []db.Repo 259 303 } 260 304 261 305 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 262 - return p.execute("timeline", w, params) 306 + return p.execute("timeline/timeline", w, params) 263 307 } 264 308 265 309 type SettingsParams struct { ··· 278 322 } 279 323 280 324 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 281 - return p.execute("knots", w, params) 325 + return p.execute("knots/index", w, params) 282 326 } 283 327 284 328 type KnotParams struct { 285 329 LoggedInUser *oauth.User 286 - DidHandleMap map[string]string 287 330 Registration *db.Registration 288 331 Members []string 332 + Repos map[string][]db.Repo 289 333 IsOwner bool 290 334 } 291 335 292 336 func (p *Pages) Knot(w io.Writer, params KnotParams) error { 293 - return p.execute("knot", w, params) 337 + return p.execute("knots/dashboard", w, params) 338 + } 339 + 340 + type KnotListingParams struct { 341 + db.Registration 342 + } 343 + 344 + func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 345 + return p.executePlain("knots/fragments/knotListing", w, params) 346 + } 347 + 348 + type KnotListingFullParams struct { 349 + Registrations []db.Registration 350 + } 351 + 352 + func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 353 + return p.executePlain("knots/fragments/knotListingFull", w, params) 354 + } 355 + 356 + type KnotSecretParams struct { 357 + Secret string 358 + } 359 + 360 + func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 361 + return p.executePlain("knots/fragments/secret", w, params) 294 362 } 295 363 296 364 type SpindlesParams struct { ··· 315 383 Spindle db.Spindle 316 384 Members []string 317 385 Repos map[string][]db.Repo 318 - DidHandleMap map[string]string 319 386 } 320 387 321 388 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 348 415 ProfileTimeline *db.ProfileTimeline 349 416 Card ProfileCard 350 417 Punchcard db.Punchcard 351 - 352 - DidHandleMap map[string]string 353 418 } 354 419 355 420 type ProfileCard struct { 356 421 UserDid string 357 422 UserHandle string 358 423 FollowStatus db.FollowStatus 359 - AvatarUri string 360 424 Followers int 361 425 Following int 362 426 ··· 371 435 LoggedInUser *oauth.User 372 436 Repos []db.Repo 373 437 Card ProfileCard 374 - 375 - DidHandleMap map[string]string 376 438 } 377 439 378 440 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { ··· 401 463 LoggedInUser *oauth.User 402 464 Profile *db.Profile 403 465 AllRepos []PinnedRepo 404 - DidHandleMap map[string]string 405 466 } 406 467 407 468 type PinnedRepo struct { ··· 413 474 return p.executePlain("user/fragments/editPins", w, params) 414 475 } 415 476 416 - type RepoActionsFragmentParams struct { 477 + type RepoStarFragmentParams struct { 417 478 IsStarred bool 418 479 RepoAt syntax.ATURI 419 480 Stats db.RepoStats 420 481 } 421 482 422 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 423 - return p.executePlain("repo/fragments/repoActions", w, params) 483 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 484 + return p.executePlain("repo/fragments/repoStar", w, params) 424 485 } 425 486 426 487 type RepoDescriptionParams struct { ··· 460 521 } 461 522 462 523 p.rctx.RepoInfo = params.RepoInfo 524 + p.rctx.RepoInfo.Ref = params.Ref 463 525 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 464 526 465 527 if params.ReadmeFileName != "" { 466 - var htmlString string 467 528 ext := filepath.Ext(params.ReadmeFileName) 468 529 switch ext { 469 530 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 470 - htmlString = p.rctx.RenderMarkdown(params.Readme) 471 531 params.Raw = false 472 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 532 + htmlString := p.rctx.RenderMarkdown(params.Readme) 533 + sanitized := p.rctx.SanitizeDefault(htmlString) 534 + params.HTMLReadme = template.HTML(sanitized) 473 535 default: 474 - htmlString = string(params.Readme) 475 536 params.Raw = true 476 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 477 537 } 478 538 } 479 539 ··· 502 562 Active string 503 563 EmailToDidOrHandle map[string]string 504 564 Pipeline *db.Pipeline 565 + DiffOpts types.DiffOpts 505 566 506 567 // singular because it's always going to be just one 507 568 VerifiedCommit commitverify.VerifiedCommits ··· 519 580 RepoInfo repoinfo.RepoInfo 520 581 Active string 521 582 BreadCrumbs [][]string 522 - BaseTreeLink string 523 - BaseBlobLink string 583 + TreePath string 524 584 types.RepoTreeResponse 525 585 } 526 586 ··· 590 650 LoggedInUser *oauth.User 591 651 RepoInfo repoinfo.RepoInfo 592 652 Active string 653 + Unsupported bool 654 + IsImage bool 655 + IsVideo bool 656 + ContentSrc string 593 657 BreadCrumbs [][]string 594 658 ShowRendered bool 595 659 RenderToggle bool ··· 606 670 p.rctx.RepoInfo = params.RepoInfo 607 671 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 608 672 htmlString := p.rctx.RenderMarkdown(params.Contents) 609 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 673 + sanitized := p.rctx.SanitizeDefault(htmlString) 674 + params.RenderedContents = template.HTML(sanitized) 610 675 } 611 676 } 612 677 613 - if params.Lines < 5000 { 614 - c := params.Contents 615 - formatter := chromahtml.New( 616 - chromahtml.InlineCode(false), 617 - chromahtml.WithLineNumbers(true), 618 - chromahtml.WithLinkableLineNumbers(true, "L"), 619 - chromahtml.Standalone(false), 620 - chromahtml.WithClasses(true), 621 - ) 678 + c := params.Contents 679 + formatter := chromahtml.New( 680 + chromahtml.InlineCode(false), 681 + chromahtml.WithLineNumbers(true), 682 + chromahtml.WithLinkableLineNumbers(true, "L"), 683 + chromahtml.Standalone(false), 684 + chromahtml.WithClasses(true), 685 + ) 622 686 623 - lexer := lexers.Get(filepath.Base(params.Path)) 624 - if lexer == nil { 625 - lexer = lexers.Fallback 626 - } 687 + lexer := lexers.Get(filepath.Base(params.Path)) 688 + if lexer == nil { 689 + lexer = lexers.Fallback 690 + } 627 691 628 - iterator, err := lexer.Tokenise(nil, c) 629 - if err != nil { 630 - return fmt.Errorf("chroma tokenize: %w", err) 631 - } 692 + iterator, err := lexer.Tokenise(nil, c) 693 + if err != nil { 694 + return fmt.Errorf("chroma tokenize: %w", err) 695 + } 632 696 633 - var code bytes.Buffer 634 - err = formatter.Format(&code, style, iterator) 635 - if err != nil { 636 - return fmt.Errorf("chroma format: %w", err) 637 - } 638 - 639 - params.Contents = code.String() 697 + var code bytes.Buffer 698 + err = formatter.Format(&code, style, iterator) 699 + if err != nil { 700 + return fmt.Errorf("chroma format: %w", err) 640 701 } 641 702 703 + params.Contents = code.String() 642 704 params.Active = "overview" 643 705 return p.executeRepo("repo/blob", w, params) 644 706 } ··· 657 719 Branches []types.Branch 658 720 Spindles []string 659 721 CurrentSpindle string 722 + Secrets []*tangled.RepoListSecrets_Secret 723 + 660 724 // TODO: use repoinfo.roles 661 725 IsCollaboratorInviteAllowed bool 662 726 } ··· 666 730 return p.executeRepo("repo/settings", w, params) 667 731 } 668 732 733 + type RepoGeneralSettingsParams struct { 734 + LoggedInUser *oauth.User 735 + RepoInfo repoinfo.RepoInfo 736 + Active string 737 + Tabs []map[string]any 738 + Tab string 739 + Branches []types.Branch 740 + } 741 + 742 + func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 743 + params.Active = "settings" 744 + return p.executeRepo("repo/settings/general", w, params) 745 + } 746 + 747 + type RepoAccessSettingsParams struct { 748 + LoggedInUser *oauth.User 749 + RepoInfo repoinfo.RepoInfo 750 + Active string 751 + Tabs []map[string]any 752 + Tab string 753 + Collaborators []Collaborator 754 + } 755 + 756 + func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 757 + params.Active = "settings" 758 + return p.executeRepo("repo/settings/access", w, params) 759 + } 760 + 761 + type RepoPipelineSettingsParams struct { 762 + LoggedInUser *oauth.User 763 + RepoInfo repoinfo.RepoInfo 764 + Active string 765 + Tabs []map[string]any 766 + Tab string 767 + Spindles []string 768 + CurrentSpindle string 769 + Secrets []map[string]any 770 + } 771 + 772 + func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 773 + params.Active = "settings" 774 + return p.executeRepo("repo/settings/pipelines", w, params) 775 + } 776 + 669 777 type RepoIssuesParams struct { 670 778 LoggedInUser *oauth.User 671 779 RepoInfo repoinfo.RepoInfo 672 780 Active string 673 781 Issues []db.Issue 674 - DidHandleMap map[string]string 675 782 Page pagination.Page 676 783 FilteringByOpen bool 677 784 } ··· 685 792 LoggedInUser *oauth.User 686 793 RepoInfo repoinfo.RepoInfo 687 794 Active string 688 - Issue db.Issue 795 + Issue *db.Issue 689 796 Comments []db.Comment 690 797 IssueOwnerHandle string 691 - DidHandleMap map[string]string 798 + 799 + OrderedReactionKinds []db.ReactionKind 800 + Reactions map[db.ReactionKind]int 801 + UserReacted map[db.ReactionKind]bool 692 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 { ··· 727 848 728 849 type SingleIssueCommentParams struct { 729 850 LoggedInUser *oauth.User 730 - DidHandleMap map[string]string 731 851 RepoInfo repoinfo.RepoInfo 732 852 Issue *db.Issue 733 853 Comment *db.Comment ··· 759 879 RepoInfo repoinfo.RepoInfo 760 880 Pulls []*db.Pull 761 881 Active string 762 - DidHandleMap map[string]string 763 882 FilteringBy db.PullState 764 883 Stacks map[string]db.Stack 884 + Pipelines map[string]db.Pipeline 765 885 } 766 886 767 887 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 791 911 LoggedInUser *oauth.User 792 912 RepoInfo repoinfo.RepoInfo 793 913 Active string 794 - DidHandleMap map[string]string 795 914 Pull *db.Pull 796 915 Stack db.Stack 797 916 AbandonedPulls []*db.Pull 798 917 MergeCheck types.MergeCheckResponse 799 918 ResubmitCheck ResubmitResult 800 919 Pipelines map[string]db.Pipeline 920 + 921 + OrderedReactionKinds []db.ReactionKind 922 + Reactions map[db.ReactionKind]int 923 + UserReacted map[db.ReactionKind]bool 801 924 } 802 925 803 926 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 806 929 } 807 930 808 931 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 932 + LoggedInUser *oauth.User 933 + RepoInfo repoinfo.RepoInfo 934 + Pull *db.Pull 935 + Stack db.Stack 936 + Diff *types.NiceDiff 937 + Round int 938 + Submission *db.PullSubmission 939 + OrderedReactionKinds []db.ReactionKind 940 + DiffOpts types.DiffOpts 817 941 } 818 942 819 943 // this name is a mouthful ··· 822 946 } 823 947 824 948 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 949 + LoggedInUser *oauth.User 950 + RepoInfo repoinfo.RepoInfo 951 + Pull *db.Pull 952 + Round int 953 + Interdiff *patchutil.InterdiffResult 954 + OrderedReactionKinds []db.ReactionKind 955 + DiffOpts types.DiffOpts 831 956 } 832 957 833 958 // this name is a mouthful ··· 918 1043 Base string 919 1044 Head string 920 1045 Diff *types.NiceDiff 1046 + DiffOpts types.DiffOpts 921 1047 922 1048 Active string 923 1049 } ··· 1009 1135 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1010 1136 params.Active = "pipelines" 1011 1137 return p.executeRepo("repo/pipelines/workflow", w, params) 1138 + } 1139 + 1140 + type PutStringParams struct { 1141 + LoggedInUser *oauth.User 1142 + Action string 1143 + 1144 + // this is supplied in the case of editing an existing string 1145 + String db.String 1146 + } 1147 + 1148 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1149 + return p.execute("strings/put", w, params) 1150 + } 1151 + 1152 + type StringsDashboardParams struct { 1153 + LoggedInUser *oauth.User 1154 + Card ProfileCard 1155 + Strings []db.String 1156 + } 1157 + 1158 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1159 + return p.execute("strings/dashboard", w, params) 1160 + } 1161 + 1162 + type StringTimelineParams struct { 1163 + LoggedInUser *oauth.User 1164 + Strings []db.String 1165 + } 1166 + 1167 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1168 + return p.execute("strings/timeline", w, params) 1169 + } 1170 + 1171 + type SingleStringParams struct { 1172 + LoggedInUser *oauth.User 1173 + ShowRendered bool 1174 + RenderToggle bool 1175 + RenderedContents template.HTML 1176 + String db.String 1177 + Stats db.StringStats 1178 + Owner identity.Identity 1179 + } 1180 + 1181 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1182 + var style *chroma.Style = styles.Get("catpuccin-latte") 1183 + 1184 + if params.ShowRendered { 1185 + switch markup.GetFormat(params.String.Filename) { 1186 + case markup.FormatMarkdown: 1187 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1188 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1189 + sanitized := p.rctx.SanitizeDefault(htmlString) 1190 + params.RenderedContents = template.HTML(sanitized) 1191 + } 1192 + } 1193 + 1194 + c := params.String.Contents 1195 + formatter := chromahtml.New( 1196 + chromahtml.InlineCode(false), 1197 + chromahtml.WithLineNumbers(true), 1198 + chromahtml.WithLinkableLineNumbers(true, "L"), 1199 + chromahtml.Standalone(false), 1200 + chromahtml.WithClasses(true), 1201 + ) 1202 + 1203 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1204 + if lexer == nil { 1205 + lexer = lexers.Fallback 1206 + } 1207 + 1208 + iterator, err := lexer.Tokenise(nil, c) 1209 + if err != nil { 1210 + return fmt.Errorf("chroma tokenize: %w", err) 1211 + } 1212 + 1213 + var code bytes.Buffer 1214 + err = formatter.Format(&code, style, iterator) 1215 + if err != nil { 1216 + return fmt.Errorf("chroma format: %w", err) 1217 + } 1218 + 1219 + params.String.Contents = code.String() 1220 + return p.execute("strings/string", w, params) 1012 1221 } 1013 1222 1014 1223 func (p *Pages) Static() http.Handler {
+26
appview/pages/templates/favicon.html
··· 1 + {{ define "favicon" }} 2 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 + <style> 4 + .favicon-text { 5 + fill: #000000; 6 + stroke: none; 7 + } 8 + 9 + @media (prefers-color-scheme: dark) { 10 + .favicon-text { 11 + fill: #ffffff; 12 + stroke: none; 13 + } 14 + } 15 + </style> 16 + 17 + <g style="display:inline"> 18 + <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 + <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 + aria-label="tangled.sh" 21 + class="favicon-text" 22 + style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 + transform="translate(11.01 6.9)"/> 24 + </g> 25 + </svg> 26 + {{ end }}
-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 }}
+62
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 + {{ template "user/fragments/picHandleLink" . }} 42 + <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 + </div> 44 + </div> 45 + <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 + {{ $repos := index $.Repos . }} 47 + {{ range $repos }} 48 + <div class="flex gap-2 items-center"> 49 + {{ i "book-marked" "size-4" }} 50 + <a href="/{{ resolve .Did }}/{{ .Name }}"> 51 + {{ .Name }} 52 + </a> 53 + </div> 54 + {{ else }} 55 + <div class="text-gray-500 dark:text-gray-400"> 56 + No repositories created yet. 57 + </div> 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + {{ 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 }}
+25 -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 + <main class="col-span-1 md:col-span-8"> 28 + {{ block "content" . }}{{ end }} 29 + </main> 30 + {{ end }} 31 + 32 + {{ block "contentAfterLayout" . }} 33 + <main class="col-span-1 md:col-span-8"> 34 + {{ block "contentAfter" . }}{{ end }} 35 + </main> 36 + {{ end }} 37 + </div> 38 + {{ end }} 39 + 40 + {{ block "footerLayout" . }} 41 + <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 + {{ template "layouts/footer" . }} 29 43 </footer> 30 - </div> 44 + {{ end }} 31 45 </body> 32 46 </html> 33 47 {{ end }}
+44 -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 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 5 45 </div> 46 + </div> 6 47 </div> 7 48 {{ end }}
+22 -5
appview/pages/templates/layouts/repobase.html
··· 5 5 {{ if .RepoInfo.Source }} 6 6 <p class="text-sm"> 7 7 <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1"}} 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 9 forked from 10 10 {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 11 <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> ··· 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 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 29 + {{ template "repo/fragments/repoStar" .RepoInfo }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 39 + </div> 23 40 </div> 24 41 {{ template "repo/fragments/repoDescription" . }} 25 42 </section> 26 43 27 44 <section 28 - class="min-h-screen w-full flex flex-col drop-shadow-sm" 45 + class="w-full flex flex-col drop-shadow-sm" 29 46 > 30 47 <nav class="w-full pl-4 overflow-auto"> 31 48 <div class="flex z-60"> ··· 47 64 {{ if eq $.Active $key }} 48 65 {{ $activeTabStyles }} 49 66 {{ else }} 50 - group-hover:bg-gray-200 dark:group-hover:bg-gray-700 67 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 68 {{ end }} 52 69 " 53 70 > ··· 64 81 </div> 65 82 </nav> 66 83 <section 67 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white" 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 68 85 > 69 86 {{ block "repoContent" . }}{{ end }} 70 87 </section>
+45 -23
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 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 5 + <a href="/" hx-boost="true" class="flex gap-2 font-bold 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 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 10 + <div id="right-items" class="flex items-center gap-2"> 23 11 {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 12 + {{ block "newButton" . }} {{ end }} 27 13 {{ block "dropDown" . }} {{ end }} 28 14 {{ else }} 29 15 <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 30 20 {{ end }} 31 21 </div> 32 22 </div> 33 23 </nav> 34 24 {{ end }} 35 25 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left nav-dropdown"> 28 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 + {{ i "plus" "w-4 h-4" }} new 30 + </summary> 31 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 + <a href="/repo/new" class="flex items-center gap-2"> 33 + {{ i "book-plus" "w-4 h-4" }} 34 + new repository 35 + </a> 36 + <a href="/strings/new" class="flex items-center gap-2"> 37 + {{ i "line-squiggle" "w-4 h-4" }} 38 + new string 39 + </a> 40 + </div> 41 + </details> 42 + {{ end }} 43 + 36 44 {{ define "dropDown" }} 37 - <details class="relative inline-block text-left"> 45 + <details class="relative inline-block text-left nav-dropdown"> 38 46 <summary 39 - class="cursor-pointer list-none" 47 + class="cursor-pointer list-none flex items-center" 40 48 > 41 - {{ didOrHandle .Did .Handle }} 49 + {{ $user := didOrHandle .Did .Handle }} 50 + {{ template "user/fragments/picHandle" $user }} 42 51 </summary> 43 52 <div 44 53 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 45 54 > 46 - <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 55 + <a href="/{{ $user }}">profile</a> 56 + <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 47 58 <a href="/knots">knots</a> 48 59 <a href="/spindles">spindles</a> 49 60 <a href="/settings">settings</a> ··· 55 66 </a> 56 67 </div> 57 68 </details> 69 + 70 + <script> 71 + document.addEventListener('click', function(event) { 72 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 73 + dropdowns.forEach(function(dropdown) { 74 + if (!dropdown.contains(event.target)) { 75 + dropdown.removeAttribute('open'); 76 + } 77 + }); 78 + }); 79 + </script> 58 80 {{ end }}
+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 overflow-y-auto"> 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 overflow-y-auto"> 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 }}
+18 -8
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 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 40 + </div> 41 + </div> 26 42 {{ 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> 43 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 30 44 {{ end }} 31 45 </main> 32 46 {{ end }} 33 - 34 - {{ define "repoAfter" }} 35 - {{ template "repo/fragments/cloneInstructions" . }} 36 - {{ 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>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
+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" "flex-shrink-0 size-4 fill-current" }} 7 + <span class="filename truncate 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" "flex-shrink-0 size-4" }} 19 + <a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 + </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="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 }}
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 1 {{ define "repo/fragments/repoDescription" }} 2 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 5 {{ else }} 6 6 <span class="italic">this repo has no description</span> 7 7 {{ end }}
+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 +
+108 -137
appview/pages/templates/repo/index.html
··· 14 14 {{ end }} 15 15 <div class="flex items-center justify-between pb-5"> 16 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-4"> 18 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1"> 17 + <div class="flex md:hidden items-center gap-2"> 18 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 22 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 25 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 26 </a> 27 + {{ template "repo/fragments/cloneDropdown" . }} 27 28 </div> 28 29 </div> 29 30 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 47 48 48 49 49 50 {{ define "branchSelector" }} 50 - <div class="flex gap-2 items-center items-stretch justify-center"> 51 - <select 52 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 53 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 54 - > 55 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 56 - {{ range .Branches }} 57 - <option 58 - value="{{ .Reference.Name }}" 59 - class="py-1" 60 - {{ if eq .Reference.Name $.Ref }} 61 - selected 62 - {{ end }} 63 - > 64 - {{ .Reference.Name }} 65 - </option> 66 - {{ end }} 67 - </optgroup> 68 - <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 69 - {{ range .Tags }} 70 - <option 71 - value="{{ .Reference.Name }}" 72 - class="py-1" 73 - {{ if eq .Reference.Name $.Ref }} 74 - selected 75 - {{ end }} 76 - > 77 - {{ .Reference.Name }} 78 - </option> 79 - {{ else }} 80 - <option class="py-1" disabled>no tags found</option> 81 - {{ end }} 82 - </optgroup> 83 - </select> 84 - <div class="flex items-center gap-2"> 51 + <div class="flex gap-2 items-center justify-between w-full"> 52 + <div class="flex gap-2 items-center"> 53 + <select 54 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 55 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 56 + > 57 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 58 + {{ range .Branches }} 59 + <option 60 + value="{{ .Reference.Name }}" 61 + class="py-1" 62 + {{ if eq .Reference.Name $.Ref }} 63 + selected 64 + {{ end }} 65 + > 66 + {{ .Reference.Name }} 67 + </option> 68 + {{ end }} 69 + </optgroup> 70 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 71 + {{ range .Tags }} 72 + <option 73 + value="{{ .Reference.Name }}" 74 + class="py-1" 75 + {{ if eq .Reference.Name $.Ref }} 76 + selected 77 + {{ end }} 78 + > 79 + {{ .Reference.Name }} 80 + </option> 81 + {{ else }} 82 + <option class="py-1" disabled>no tags found</option> 83 + {{ end }} 84 + </optgroup> 85 + </select> 86 + <div class="flex items-center gap-2"> 85 87 {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 86 88 {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 87 89 {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} ··· 115 117 <span>sync</span> 116 118 </button> 117 119 {{ end }} 118 - <a 119 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 120 - class="btn flex items-center gap-2 no-underline hover:no-underline" 121 - title="Compare branches or tags" 122 - > 123 - {{ i "git-compare" "w-4 h-4" }} 124 - </a> 120 + <a 121 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 122 + class="btn flex items-center gap-2 no-underline hover:no-underline" 123 + title="Compare branches or tags" 124 + > 125 + {{ i "git-compare" "w-4 h-4" }} 126 + </a> 127 + </div> 128 + </div> 129 + 130 + <!-- Clone dropdown in top right --> 131 + <div class="hidden md:flex items-center "> 132 + {{ template "repo/fragments/cloneDropdown" . }} 125 133 </div> 126 - </div> 134 + </div> 127 135 {{ end }} 128 136 129 137 {{ 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" }} 138 + <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" > 139 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 136 140 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> 141 + {{ range .Files }} 142 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 143 + <div class="col-span-2"> 144 + {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 145 + {{ $icon := "folder" }} 146 + {{ $iconStyle := "size-4 fill-current" }} 150 147 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 }} 148 + {{ if .IsFile }} 149 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 150 + {{ $icon = "file" }} 151 + {{ $iconStyle = "size-4" }} 152 + {{ end }} 153 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 154 + <div class="flex items-center gap-2"> 155 + {{ i $icon $iconStyle "flex-shrink-0" }} 156 + <span class="truncate">{{ .Name }}</span> 157 + </div> 158 + </a> 159 + </div> 160 160 161 - {{ range .Files }} 162 - {{ if .IsFile }} 163 - <div class="{{ $containerstyle }}"> 164 - <div class="flex justify-between items-center"> 165 - <a 166 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 167 - class="{{ $linkstyle }}" 168 - > 169 - <div class="flex items-center gap-2"> 170 - {{ i "file" "size-4" }}{{ .Name }} 171 - </div> 172 - </a> 173 - 174 - {{ if .LastCommit }} 175 - <time class="text-xs text-gray-500 dark:text-gray-400" 176 - >{{ timeFmt .LastCommit.When }}</time 177 - > 178 - {{ end }} 179 - </div> 180 - </div> 181 - {{ end }} 182 - {{ end }} 183 - </div> 161 + <div class="text-sm col-span-1 text-right"> 162 + {{ with .LastCommit }} 163 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }} 168 + </div> 184 169 {{ end }} 185 170 186 171 {{ define "rightInfo" }} ··· 194 179 {{ define "commitLog" }} 195 180 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 196 181 <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> 182 + <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"> 183 + {{ i "logs" "w-4 h-4" }} commits 184 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 204 185 </a> 205 186 </div> 206 187 <div class="flex flex-col gap-6"> ··· 238 219 </div> 239 220 240 221 <!-- commit info bar --> 241 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 222 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 242 223 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 243 224 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 244 225 {{ if $verified }} ··· 266 247 {{ end }}" 267 248 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 268 249 >{{ if $didOrHandle }} 269 - {{ $didOrHandle }} 250 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 270 251 {{ else }} 271 252 {{ .Author.Name }} 272 253 {{ end }}</a 273 254 > 274 255 </span> 275 256 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 276 - <span>{{ timeFmt .Committer.When }}</span> 257 + {{ template "repo/fragments/time" .Committer.When }} 277 258 278 259 <!-- tags/branches --> 279 260 {{ $tagsForCommit := index $.TagMap .Hash.String }} ··· 302 283 {{ define "branchList" }} 303 284 {{ if gt (len .BranchesTrunc) 0 }} 304 285 <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> 286 + <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"> 287 + {{ i "git-branch" "w-4 h-4" }} branches 288 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 312 289 </a> 313 290 <div class="flex flex-col gap-1"> 314 291 {{ range .BranchesTrunc }} 315 - <div class="text-base flex items-center justify-between"> 316 - <div class="flex items-center gap-2"> 292 + <div class="text-base flex items-center justify-between overflow-hidden"> 293 + <div class="flex items-center gap-2 min-w-0 flex-1"> 317 294 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 318 - class="inline no-underline hover:underline dark:text-white"> 295 + class="inline-block truncate no-underline hover:underline dark:text-white"> 319 296 {{ .Reference.Name }} 320 297 </a> 321 298 {{ if .Commit }} 322 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 323 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time> 299 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 300 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 324 301 {{ end }} 325 302 {{ if .IsDefault }} 326 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 327 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 303 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 304 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 328 305 {{ end }} 329 306 </div> 330 307 {{ if ne $.Ref .Reference.Name }} 331 308 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 332 - class="text-xs flex gap-2 items-center" 309 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 333 310 title="Compare branches or tags"> 334 311 {{ i "git-compare" "w-3 h-3" }} compare 335 312 </a> 336 - {{end}} 313 + {{ end }} 337 314 </div> 338 315 {{ end }} 339 316 </div> ··· 345 322 {{ if gt (len .TagsTrunc) 0 }} 346 323 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 347 324 <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> 325 + <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"> 326 + {{ i "tags" "w-4 h-4" }} tags 327 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 355 328 </a> 356 329 </div> 357 330 <div class="flex flex-col gap-1"> ··· 366 339 </div> 367 340 <div> 368 341 {{ with .Tag }} 369 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time> 342 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span> 370 343 {{ end }} 371 344 {{ if eq $idx 0 }} 372 345 {{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }} ··· 382 355 {{ end }} 383 356 384 357 {{ define "repoAfter" }} 385 - {{- if .HTMLReadme -}} 358 + {{- if or .HTMLReadme .Readme -}} 386 359 <section 387 360 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 361 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 390 363 dark:[&_pre]:border dark:[&_pre]:border-gray-700 391 364 {{ end }}" 392 365 > 393 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll"> 394 - {{- .HTMLReadme -}} 366 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 367 + {{- .Readme -}} 395 368 </pre> 396 369 {{- else -}} 397 370 {{ .HTMLReadme }} 398 371 {{- end -}}</article> 399 372 </section> 400 373 {{- end -}} 401 - 402 - {{ template "repo/fragments/cloneInstructions" . }} 403 374 {{ end }}
+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 -17
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"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 + 7 + <!-- show user "hats" --> 8 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 + {{ if $isIssueAuthor }} 10 + <span class="before:content-['ยท']"></span> 11 + author 12 + {{ end }} 7 13 8 14 <span class="before:content-['ยท']"></span> 9 15 <a ··· 11 17 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 12 18 id="{{ .CommentId }}"> 13 19 {{ if .Deleted }} 14 - deleted {{ .Deleted | timeFmt }} 20 + deleted {{ template "repo/fragments/time" .Deleted }} 15 21 {{ else if .Edited }} 16 - edited {{ .Edited | timeFmt }} 22 + edited {{ template "repo/fragments/time" .Edited }} 17 23 {{ else }} 18 - {{ .Created | timeFmt }} 24 + {{ template "repo/fragments/time" .Created }} 19 25 {{ end }} 20 26 </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 27 30 28 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 29 {{ if and $isCommentOwner (not .Deleted) }} 32 - <button 33 - class="btn px-2 py-1 text-sm" 30 + <button 31 + class="btn px-2 py-1 text-sm" 34 32 hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 35 33 hx-swap="outerHTML" 36 34 hx-target="#comment-container-{{.CommentId}}" 37 35 > 38 36 {{ i "pencil" "w-4 h-4" }} 39 37 </button> 40 - <button 38 + <button 41 39 class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 42 40 hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 43 41 hx-confirm="Are you sure you want to delete your comment?"
+19 -7
appview/pages/templates/repo/issues/issue.html
··· 11 11 {{ define "repoContent" }} 12 12 <header class="pb-4"> 13 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 14 + {{ .Issue.Title | description }} 15 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 16 </h1> 17 17 </header> ··· 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.AtUri) 58 + }} 59 + {{ end }} 60 + </div> 49 61 </section> 50 62 {{ end }} 51 63 ··· 58 70 {{ if gt $index 0 }} 59 71 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 60 72 {{ end }} 61 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 73 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 62 74 </div> 63 75 {{ end }} 64 76 </section> ··· 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"
+3 -6
appview/pages/templates/repo/issues/issues.html
··· 45 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 46 class="no-underline hover:underline" 47 47 > 48 - {{ .Title }} 48 + {{ .Title | description }} 49 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 50 </a> 51 51 </div> ··· 65 65 </span> 66 66 67 67 <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandle" $owner }} 68 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 70 69 </span> 71 70 72 71 <span class="before:content-['ยท']"> 73 - <time> 74 - {{ .Created | timeFmt }} 75 - </time> 72 + {{ template "repo/fragments/time" .Created }} 76 73 </span> 77 74 78 75 <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 }}
+2 -2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 23 23 </div> 24 24 {{ else if $allFail }} 25 25 <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-600" }} 26 + {{ i "x" "size-4 text-red-500" }} 27 27 <span>0/{{ $total }}</span> 28 28 </div> 29 29 {{ else if $allTimeout }} 30 30 <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-400" }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 32 <span>0/{{ $total }}</span> 33 33 </div> 34 34 {{ else }}
+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 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 20 {{ else if eq $kind "timeout" }} 21 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 23 {{ else }} 24 24 {{ $icon = "x" }} 25 25 {{ $color = "text-red-600 dark:text-red-500" }}
+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 }}
+10 -14
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 }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 25 26 {{ with .Pipeline }} 26 27 {{ $id := .Id }} 27 28 <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 28 29 {{ range $name, $all := .Statuses }} 29 30 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 30 31 <div 31 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 32 33 {{ $lastStatus := $all.Latest }} 33 34 {{ $kind := $lastStatus.Status.String }} 34 35 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 36 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 45 37 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 46 38 {{ $name }} 47 39 </div> 48 40 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 49 41 <span class="font-bold">{{ $kind }}</span> 50 - <time>{{ $time }}</time> 42 + {{ if .TimeTaken }} 43 + {{ template "repo/fragments/duration" .TimeTaken }} 44 + {{ else }} 45 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 46 + {{ end }} 51 47 </div> 52 48 </div> 53 49 </a>
+20 -4
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 2 <header class="pb-4"> 3 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 4 + {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 6 </h1> 7 7 </header> ··· 17 17 {{ $icon = "git-merge" }} 18 18 {{ end }} 19 19 20 + {{ $owner := resolve .Pull.OwnerDid }} 20 21 <section class="mt-2"> 21 22 <div class="flex items-center gap-2"> 22 23 <div ··· 28 29 </div> 29 30 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 31 opened by 31 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandle" $owner }} 32 + {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }} 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">
+7 -9
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 9 9 </div> 10 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 12 + {{ .Title | description }} 13 13 </span> 14 14 </div> 15 15 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 overflow-y-auto"> 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 overflow-y-auto"> 77 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 + </div> 79 + {{end}}
+15 -22
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> 50 - {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 49 + <span class="gap-1 flex items-center"> 50 + {{ $owner := resolve $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by <a href="/{{ $owner }}">{{ $owner }}</a> 56 + by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 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> ··· 122 122 {{ end }} 123 123 </div> 124 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 125 + <span>{{ .Title | description }}</span> 126 126 {{ if gt (len .Body) 0 }} 127 127 <button 128 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 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 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 156 155 <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> 156 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 157 </div> 159 158 <div class="prose dark:prose-invert"> 160 159 {{ $c.Body | markdown }} ··· 179 178 {{ end }} 180 179 </div> 181 180 </details> 182 - <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 183 181 {{ end }} 184 182 {{ end }} 185 183 {{ end }} ··· 277 275 {{ $lastStatus := $all.Latest }} 278 276 {{ $kind := $lastStatus.Status.String }} 279 277 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 278 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 290 279 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 291 280 {{ $name }} 292 281 </div> 293 282 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 294 283 <span class="font-bold">{{ $kind }}</span> 295 - <time>{{ $time }}</time> 284 + {{ if .TimeTaken }} 285 + {{ template "repo/fragments/duration" .TimeTaken }} 286 + {{ else }} 287 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 288 + {{ end }} 296 289 </div> 297 290 </div> 298 291 </a>
+47 -59
appview/pages/templates/repo/pulls/pulls.html
··· 50 50 <div class="px-6 py-4 z-5"> 51 51 <div class="pb-2"> 52 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 53 + {{ .Title | description }} 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 59 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 59 {{ $icon := "ban" }} 61 60 ··· 76 75 </span> 77 76 78 77 <span class="ml-1"> 79 - {{ template "user/fragments/picHandle" $owner }} 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 80 79 </span> 81 80 82 - <span> 83 - <time> 84 - {{ .Created | timeFmt }} 85 - </time> 81 + <span class="before:content-['ยท']"> 82 + {{ template "repo/fragments/time" .Created }} 86 83 </span> 87 84 85 + 86 + {{ $latestRound := .LastRoundNumber }} 87 + {{ $lastSubmission := index .Submissions $latestRound }} 88 + 88 89 <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> 90 + {{ $commentCount := len $lastSubmission.Comments }} 91 + {{ $s := "s" }} 92 + {{ if eq $commentCount 1 }} 93 + {{ $s = "" }} 94 + {{ end }} 95 + 96 + {{ len $lastSubmission.Comments}} comment{{$s}} 93 97 </span> 94 - {{ if not .IsPatchBased }} 95 - from 96 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 97 - {{ if .IsForkBased }} 98 - {{ if .PullSource.Repo }} 99 - <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 100 - {{- else -}} 101 - <span class="italic">[deleted fork]</span> 102 - {{- end -}} 103 - {{- end -}} 104 - {{- .PullSource.Branch -}} 105 - </span> 106 - {{ end }} 107 - <span class="before:content-['ยท']"> 108 - {{ $latestRound := .LastRoundNumber }} 109 - {{ $lastSubmission := index .Submissions $latestRound }} 110 - round 111 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 112 - #{{ .LastRoundNumber }} 113 - </span> 114 - {{ $commentCount := len $lastSubmission.Comments }} 115 - {{ $s := "s" }} 116 - {{ if eq $commentCount 1 }} 117 - {{ $s = "" }} 118 - {{ end }} 119 98 120 - {{ if eq $commentCount 0 }} 121 - awaiting comments 122 - {{ else }} 123 - recieved {{ len $lastSubmission.Comments}} comment{{$s}} 124 - {{ end }} 99 + <span class="before:content-['ยท']"> 100 + round 101 + <span class="font-mono"> 102 + #{{ .LastRoundNumber }} 103 + </span> 125 104 </span> 126 - </p> 105 + 106 + {{ $pipeline := index $.Pipelines .LatestSha }} 107 + {{ if and $pipeline $pipeline.Id }} 108 + <span class="before:content-['ยท']"></span> 109 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 + {{ end }} 111 + </div> 127 112 </div> 128 113 {{ if .StackId }} 129 114 {{ $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> 115 + {{ if gt (len $otherPulls) 0 }} 116 + <details class="bg-white dark:bg-gray-800 group"> 117 + <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 118 + {{ $s := "s" }} 119 + {{ if eq (len $otherPulls) 1 }} 120 + {{ $s = "" }} 121 + {{ end }} 122 + <div class="group-open:hidden flex items-center gap-2"> 123 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 124 + </div> 125 + <div class="hidden group-open:flex items-center gap-2"> 126 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 127 + </div> 128 + </summary> 129 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 130 + </details> 131 + {{ end }} 145 132 {{ end }} 146 133 </div> 147 134 {{ end }} ··· 153 140 {{ $root := index . 1 }} 154 141 <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 142 {{ range $pull := $list }} 143 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 156 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 157 145 <div class="flex gap-2 items-center px-6"> 158 146 <div class="flex-grow min-w-0 w-full py-2"> 159 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 147 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 160 148 </div> 161 149 </div> 162 150 </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 }}
+145
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 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 44 + Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 48 + </option> 49 + {{ range $.Spindles }} 50 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 51 + {{ . }} 52 + </option> 53 + {{ end }} 54 + </select> 55 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 56 + {{ i "check" "size-4" }} 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + </form> 60 + {{ end }} 61 + </div> 62 + {{ end }} 63 + 64 + {{ define "secretSettings" }} 65 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 66 + <div class="col-span-1 md:col-span-2"> 67 + <h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2> 68 + <p class="text-gray-500 dark:text-gray-400"> 69 + Secrets are accessible in workflow runs via environment variables. Anyone 70 + with collaborator access to this repository can add and use secrets in 71 + workflow runs. 72 + </p> 73 + </div> 74 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 75 + {{ template "addSecretButton" . }} 76 + </div> 77 + </div> 78 + <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"> 79 + {{ range .Secrets }} 80 + {{ template "repo/settings/fragments/secretListing" (list $ .) }} 81 + {{ else }} 82 + <div class="flex items-center justify-center p-2 text-gray-500"> 83 + no secrets added yet 84 + </div> 85 + {{ end }} 86 + </div> 87 + {{ end }} 88 + 89 + {{ define "addSecretButton" }} 90 + <button 91 + class="btn flex items-center gap-2" 92 + popovertarget="add-secret-modal" 93 + popovertargetaction="toggle"> 94 + {{ i "plus" "size-4" }} 95 + add secret 96 + </button> 97 + <div 98 + id="add-secret-modal" 99 + popover 100 + 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"> 101 + {{ template "addSecretModal" . }} 102 + </div> 103 + {{ end}} 104 + 105 + {{ define "addSecretModal" }} 106 + <form 107 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 108 + hx-indicator="#spinner" 109 + hx-swap="none" 110 + class="flex flex-col gap-2" 111 + > 112 + <p class="uppercase p-0">ADD SECRET</p> 113 + <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 + <input 115 + type="text" 116 + id="secret-key" 117 + name="key" 118 + required 119 + placeholder="SECRET_NAME" 120 + /> 121 + <textarea 122 + type="text" 123 + id="secret-value" 124 + name="value" 125 + required 126 + placeholder="secret value"></textarea> 127 + <div class="flex gap-2 pt-2"> 128 + <button 129 + type="button" 130 + popovertarget="add-secret-modal" 131 + popovertargetaction="hide" 132 + 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" 133 + > 134 + {{ i "x" "size-4" }} cancel 135 + </button> 136 + <button type="submit" class="btn w-1/2 flex items-center"> 137 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 138 + <span id="spinner" class="group"> 139 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 140 + </span> 141 + </button> 142 + </div> 143 + <div id="add-secret-error" class="text-red-500 dark:text-red-400"></div> 144 + </form> 145 + {{ end }}
-138
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 6 - 7 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 8 - {{ range .Collaborators }} 9 - <div id="collaborator" class="mb-2"> 10 - <a 11 - href="/{{ didOrHandle .Did .Handle }}" 12 - class="no-underline hover:underline text-black dark:text-white" 13 - > 14 - {{ didOrHandle .Did .Handle }} 15 - </a> 16 - <div> 17 - <span class="text-sm text-gray-500 dark:text-gray-400"> 18 - {{ .Role }} 19 - </span> 20 - </div> 21 - </div> 22 - {{ end }} 23 - </div> 24 - 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 29 - > 30 - <label for="collaborator" class="dark:text-white"> 31 - add collaborator 32 - </label> 33 - <input 34 - type="text" 35 - id="collaborator" 36 - name="collaborator" 37 - required 38 - class="dark:bg-gray-700 dark:text-white" 39 - placeholder="enter did or handle" 40 - > 41 - <button 42 - class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" 43 - type="text" 44 - > 45 - <span>add</span> 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - </form> 49 - {{ end }} 50 - 51 - <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 54 - > 55 - <label for="branch">default branch</label> 56 - <div class="flex gap-2 items-center"> 57 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 58 - <option 59 - value="" 60 - disabled 61 - selected 62 - > 63 - Choose a default branch 64 - </option> 65 - {{ range .Branches }} 66 - <option 67 - value="{{ .Name }}" 68 - class="py-1" 69 - {{ if .IsDefault }} 70 - selected 71 - {{ end }} 72 - > 73 - {{ .Name }} 74 - </option> 75 - {{ end }} 76 - </select> 77 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 78 - <span>save</span> 79 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 80 - </button> 81 - </div> 82 - </form> 83 - 84 - {{ if .RepoInfo.Roles.IsOwner }} 85 - <form 86 - hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" 87 - class="mt-6 group" 88 - > 89 - <label for="spindle">spindle</label> 90 - <div class="flex gap-2 items-center"> 91 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 92 - <option 93 - value="" 94 - selected 95 - > 96 - None 97 - </option> 98 - {{ range .Spindles }} 99 - <option 100 - value="{{ . }}" 101 - class="py-1" 102 - {{ if eq . $.CurrentSpindle }} 103 - selected 104 - {{ end }} 105 - > 106 - {{ . }} 107 - </option> 108 - {{ end }} 109 - </select> 110 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 111 - <span>save</span> 112 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 - </button> 114 - </div> 115 - </form> 116 - {{ end }} 117 - 118 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 119 - <form 120 - hx-confirm="Are you sure you want to delete this repository?" 121 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 122 - class="mt-6" 123 - hx-indicator="#delete-repo-spinner" 124 - > 125 - <label for="branch">delete repository</label> 126 - <button class="btn my-2 flex items-center" type="text"> 127 - <span>delete</span> 128 - <span id="delete-repo-spinner" class="group"> 129 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 130 - </span> 131 - </button> 132 - <span> 133 - Deleting a repository is irreversible and permanent. 134 - </span> 135 - </form> 136 - {{ end }} 137 - 138 - {{ end }}
+10 -4
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> ··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+29 -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-8 md:col-span-4"> 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 "flex-shrink-0" }} 69 + <span class="truncate">{{ .Name }}</span> 70 + </div> 71 + </a> 72 + </div> 73 + 74 + <div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden"> 75 + {{ with .LastCommit }} 76 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 77 + {{ end }} 68 78 </div> 69 - </div> 70 - {{ end }} 71 - {{ end }} 72 79 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 }} 80 + <div class="col-span-4 md:col-span-2 text-sm text-right"> 81 + {{ with .LastCommit }} 82 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 + {{ end }} 85 84 </div> 86 - </div> 85 + </div> 87 86 {{ end }} 88 - {{ end }} 87 + 89 88 </div> 90 89 </main> 91 90 {{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 }}
+2 -4
appview/pages/templates/spindles/dashboard.html
··· 42 42 <div> 43 43 <div class="flex justify-between items-center"> 44 44 <div class="flex items-center gap-2"> 45 - {{ i "user" "size-4" }} 46 - {{ $user := index $.DidHandleMap . }} 47 - <a href="/{{ $user }}">{{ $user }}</a> 45 + {{ template "user/fragments/picHandleLink" . }} 48 46 </div> 49 47 {{ if ne $.LoggedInUser.Did . }} 50 48 {{ block "removeMemberButton" (list $ . ) }} {{ end }} ··· 109 107 hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 110 108 hx-swap="none" 111 109 hx-vals='{"member": "{{$member}}" }' 112 - hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?" 110 + hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 113 111 > 114 112 {{ i "user-minus" "w-4 h-4" }} 115 113 remove
+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">
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+90
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 + rows="20" 36 + spellcheck="false" 37 + placeholder="Paste your string here!" 38 + required>{{ .String.Contents }}</textarea> 39 + <div class="flex justify-between items-center"> 40 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 + <span id="line-count">0 lines</span> 42 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 43 + <span id="byte-count">0 bytes</span> 44 + </div> 45 + <div id="actions" class="flex gap-2 items-center"> 46 + {{ if eq .Action "edit" }} 47 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 48 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 + {{ i "x" "size-4" }} 50 + <span class="hidden md:inline">cancel</span> 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </a> 53 + {{ end }} 54 + <button 55 + type="submit" 56 + id="new-button" 57 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 58 + > 59 + <span class="inline-flex items-center gap-2"> 60 + {{ i "arrow-up" "w-4 h-4" }} 61 + publish 62 + </span> 63 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + <script> 70 + (function() { 71 + const textarea = document.getElementById('content-textarea'); 72 + const lineCount = document.getElementById('line-count'); 73 + const byteCount = document.getElementById('byte-count'); 74 + function updateStats() { 75 + const content = textarea.value; 76 + const lines = content === '' ? 0 : content.split('\n').length; 77 + const bytes = new TextEncoder().encode(content).length; 78 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 + } 81 + textarea.addEventListener('input', updateStats); 82 + textarea.addEventListener('paste', () => { 83 + setTimeout(updateStats, 0); 84 + }); 85 + updateStats(); 86 + })(); 87 + </script> 88 + <div id="error" class="error dark:text-red-400"></div> 89 + </form> 90 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+88
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + {{ end }} 51 + </span> 52 + </section> 53 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 55 + <span> 56 + {{ .String.Filename }} 57 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 58 + <span> 59 + {{ with .String.Edited }} 60 + edited {{ template "repo/fragments/shortTimeAgo" . }} 61 + {{ else }} 62 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 63 + {{ end }} 64 + </span> 65 + </span> 66 + <div> 67 + <span>{{ .Stats.LineCount }} lines</span> 68 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 69 + <span>{{ byteFmt .Stats.ByteCount }}</span> 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 72 + {{ if .RenderToggle }} 73 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 74 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 75 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 76 + </a> 77 + {{ end }} 78 + </div> 79 + </div> 80 + <div class="overflow-x-auto overflow-y-hidden relative"> 81 + {{ if .ShowRendered }} 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 + {{ else }} 84 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 + {{ end }} 86 + </div> 87 + </section> 88 + {{ end }}
+65
appview/pages/templates/strings/timeline.html
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + {{ block "timeline" $ }}{{ end }} 9 + {{ end }} 10 + 11 + {{ define "timeline" }} 12 + <div> 13 + <div class="p-6"> 14 + <p class="text-xl font-bold dark:text-white">All strings</p> 15 + </div> 16 + 17 + <div class="flex flex-col gap-4"> 18 + {{ range $i, $s := .Strings }} 19 + <div class="relative"> 20 + {{ if ne $i 0 }} 21 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 22 + {{ end }} 23 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 24 + {{ template "stringCard" $s }} 25 + </div> 26 + </div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "stringCard" }} 33 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 + <div class="font-medium dark:text-white flex gap-2 items-center"> 35 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 36 + </div> 37 + {{ with .Description }} 38 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 39 + {{ . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ template "stringCardInfo" . }} 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "stringCardInfo" }} 48 + {{ $stat := .Stats }} 49 + {{ $resolved := resolve .Did.String }} 50 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 + {{ template "user/fragments/picHandle" $resolved }} 53 + </a> 54 + <span class="select-none [&:before]:content-['ยท']"></span> 55 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 + <span class="select-none [&:before]:content-['ยท']"></span> 57 + {{ with .Edited }} 58 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 59 + {{ else }} 60 + {{ template "repo/fragments/shortTimeAgo" .Created }} 61 + {{ end }} 62 + </div> 63 + {{ end }} 64 + 65 +
+183
appview/pages/templates/timeline/timeline.html
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 175 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 + <span id="followers">{{ .Followers }} followers</span> 177 + <span class="select-none after:content-['ยท']"></span> 178 + <span id="following">{{ .Following }} following</span> 179 + </div> 180 + {{ end }} 181 + </div> 182 + </div> 183 + {{ end }}
-130
appview/pages/templates/timeline.html
··· 1 - {{ define "title" }}timeline{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline ยท tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 8 - {{ end }} 9 - 10 - {{ define "topbar" }} 11 - {{ template "layouts/topbar" $ }} 12 - {{ end }} 13 - 14 - {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 21 - {{ end }} 22 - 23 - {{ define "hero" }} 24 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 26 - 27 - <p class="text-lg"> 28 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 29 - </p> 30 - <p class="text-lg"> 31 - we envision a place where developers have complete ownership of their 32 - code, open source communities can freely self-govern and most 33 - importantly, coding can be social and fun again. 34 - </p> 35 - 36 - <div class="flex gap-6 items-center"> 37 - <a href="/login" class="no-underline hover:no-underline "> 38 - <button class="btn flex gap-2 px-4 items-center"> 39 - join now {{ i "arrow-right" "size-4" }} 40 - </button> 41 - </a> 42 - </div> 43 - </div> 44 - {{ end }} 45 - 46 - {{ define "timeline" }} 47 - <div> 48 - <div class="p-6"> 49 - <p class="text-xl font-bold dark:text-white">Timeline</p> 50 - </div> 51 - 52 - <div class="flex flex-col gap-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 }} 126 - </div> 127 - {{ end }} 128 - </div> 129 - </div> 130 - {{ 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 flex flex-col gap-4" 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"> 62 + <label for="username">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"> 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 text-base" 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 }}
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 27 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 29 29 <div class="flex justify-between items-center w-full"> 30 - <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 31 <div class="flex gap-1 items-center"> 32 32 {{ i "star" "size-4 fill-current" }} 33 33 <span>{{ .RepoStats.StarCount }}</span>
+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 }}
+6
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 + {{ define "user/fragments/picHandleLink" }} 2 + {{ $resolved := resolve . }} 3 + <a href="/{{ $resolved }}" class="flex items-center"> 4 + {{ template "user/fragments/picHandle" $resolved }} 5 + </a> 6 + {{ end }}
+8 -7
appview/pages/templates/user/fragments/profileCard.html
··· 2 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 14 - {{ didOrHandle .UserDid .UserHandle }} 15 - </p> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ didOrHandle .UserDid .UserHandle }} 14 + </p> 15 + <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 16 17 17 18 <div class="md:hidden"> 18 19 {{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+63
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 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 8 + <div class="font-medium dark:text-white flex items-center"> 9 + {{ if .Source }} 10 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 11 + {{ else }} 12 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 13 + {{ end }} 14 + 15 + {{ $repoOwner := resolve .Did }} 16 + {{- if $fullName -}} 17 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 + {{- else -}} 19 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 + {{- end -}} 21 + </div> 22 + {{ with .Description }} 23 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 24 + {{ . | description }} 25 + </div> 26 + {{ end }} 27 + 28 + {{ if .RepoStats }} 29 + {{ block "repoStats" .RepoStats }}{{ end }} 30 + {{ end }} 31 + </div> 32 + {{ end }} 33 + {{ end }} 34 + 35 + {{ define "repoStats" }} 36 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 + {{ with .Language }} 38 + <div class="flex gap-2 items-center text-sm"> 39 + <div class="size-2 rounded-full" 40 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 41 + <span>{{ . }}</span> 42 + </div> 43 + {{ end }} 44 + {{ with .StarCount }} 45 + <div class="flex gap-1 items-center text-sm"> 46 + {{ i "star" "w-3 h-3 fill-current" }} 47 + <span>{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + {{ with .IssueCount.Open }} 51 + <div class="flex gap-1 items-center text-sm"> 52 + {{ i "circle-dot" "w-3 h-3" }} 53 + <span>{{ . }}</span> 54 + </div> 55 + {{ end }} 56 + {{ with .PullCount.Open }} 57 + <div class="flex gap-1 items-center text-sm"> 58 + {{ i "git-pull-request" "w-3 h-3" }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+14 -34
appview/pages/templates/user/login.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 - <meta 11 - property="og:title" 12 - content="login ยท tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.sh/login" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="login to tangled" 21 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 22 10 <script src="/static/htmx.min.js"></script> 23 - <link 24 - rel="stylesheet" 25 - href="/static/tw.css?{{ cssContentHash }}" 26 - type="text/css" 27 - /> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 28 12 <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 35 17 tangled 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 36 + placeholder="akshay.tngl.sh" 54 37 /> 55 38 <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. 39 + Use your <a href="https://atproto.com">ATProto</a> 40 + handle to log in. If you're unsure, this is likely 41 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 42 </span> 61 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 62 45 63 46 <button 64 - class="btn w-full my-2 mt-6" 47 + class="btn w-full my-2 mt-6 text-base " 65 48 type="submit" 66 49 id="login-button" 67 50 tabindex="3" ··· 70 53 </button> 71 54 </form> 72 55 <p class="text-sm text-gray-500"> 73 - Join our <a href="https://chat.tangled.sh">Discord</a> or 74 - IRC channel: 75 - <a href="https://web.libera.chat/#tangled" 76 - ><code>#tangled</code> on Libera Chat</a 77 - >. 56 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 78 57 </p> 58 + 79 59 <p id="login-msg" class="error w-full"></p> 80 60 </main> 81 61 </body>
+20 -69
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> ··· 50 50 </div> 51 51 {{ else }} 52 52 <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }} 54 - {{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }} 55 - {{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }} 53 + {{ block "repoEvents" .RepoEvents }} {{ end }} 54 + {{ block "issueEvents" .IssueEvents }} {{ end }} 55 + {{ block "pullEvents" .PullEvents }} {{ end }} 56 56 </div> 57 57 {{ end }} 58 58 </div> ··· 66 66 {{ end }} 67 67 68 68 {{ define "repoEvents" }} 69 - {{ $items := index . 0 }} 70 - {{ $handleMap := index . 1 }} 71 - 72 - {{ if gt (len $items) 0 }} 69 + {{ if gt (len .) 0 }} 73 70 <details> 74 71 <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 72 <div class="flex flex-wrap items-center gap-2"> 76 73 {{ i "book-plus" "w-4 h-4" }} 77 - created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 74 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 78 75 </div> 79 76 </summary> 80 77 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 81 - {{ range $items }} 78 + {{ range . }} 82 79 <div class="flex flex-wrap items-center gap-2"> 83 80 <span class="text-gray-500 dark:text-gray-400"> 84 81 {{ if .Source }} ··· 87 84 {{ i "book-plus" "w-4 h-4" }} 88 85 {{ end }} 89 86 </span> 90 - <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 87 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 91 88 {{- .Repo.Name -}} 92 89 </a> 93 90 </div> ··· 98 95 {{ end }} 99 96 100 97 {{ define "issueEvents" }} 101 - {{ $i := index . 0 }} 102 - {{ $items := $i.Items }} 103 - {{ $stats := $i.Stats }} 104 - {{ $handleMap := index . 1 }} 98 + {{ $items := .Items }} 99 + {{ $stats := .Stats }} 105 100 106 101 {{ if gt (len $items) 0 }} 107 102 <details> ··· 129 124 </summary> 130 125 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 131 126 {{ range $items }} 132 - {{ $repoOwner := index $handleMap .Metadata.Repo.Did }} 127 + {{ $repoOwner := resolve .Metadata.Repo.Did }} 133 128 {{ $repoName := .Metadata.Repo.Name }} 134 129 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 135 130 ··· 163 158 {{ end }} 164 159 165 160 {{ define "pullEvents" }} 166 - {{ $i := index . 0 }} 167 - {{ $items := $i.Items }} 168 - {{ $stats := $i.Stats }} 169 - {{ $handleMap := index . 1 }} 161 + {{ $items := .Items }} 162 + {{ $stats := .Stats }} 170 163 {{ if gt (len $items) 0 }} 171 164 <details> 172 165 <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> ··· 200 193 </summary> 201 194 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 202 195 {{ range $items }} 203 - {{ $repoOwner := index $handleMap .Repo.Did }} 196 + {{ $repoOwner := resolve .Repo.Did }} 204 197 {{ $repoName := .Repo.Name }} 205 198 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 206 199 ··· 258 251 </button> 259 252 {{ end }} 260 253 </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4"> 254 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 262 255 {{ 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> 256 + {{ template "user/fragments/repoCard" (list $ . false) }} 285 257 {{ else }} 286 258 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 287 259 {{ end }} ··· 295 267 <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 296 268 <div id="collaborating" class="grid grid-cols-1 gap-4"> 297 269 {{ 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> 270 + {{ template "user/fragments/repoCard" (list $ . true) }} 320 271 {{ else }} 321 272 <p class="px-6 dark:text-white">This user is not collaborating.</p> 322 273 {{ 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 }}
+53
appview/pages/templates/user/signup.html
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+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 + }
+108 -129
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" 22 + "tangled.sh/tangled.sh/core/appview/pages/markup" 23 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 24 + "tangled.sh/tangled.sh/core/idresolver" 24 25 "tangled.sh/tangled.sh/core/knotclient" 25 26 "tangled.sh/tangled.sh/core/patchutil" 27 + "tangled.sh/tangled.sh/core/tid" 26 28 "tangled.sh/tangled.sh/core/types" 27 29 28 30 "github.com/bluekeyes/go-gitdiff/gitdiff" 29 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 30 - "github.com/bluesky-social/indigo/atproto/syntax" 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 ··· 151 151 } 152 152 } 153 153 154 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 155 - didHandleMap := make(map[string]string) 156 - for _, identity := range resolvedIds { 157 - if !identity.Handle.IsInvalidHandle() { 158 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 159 - } else { 160 - didHandleMap[identity.DID.String()] = identity.DID.String() 161 - } 162 - } 163 - 164 154 mergeCheckResponse := s.mergeCheck(f, pull, stack) 165 155 resubmitResult := pages.Unknown 166 156 if user != nil && user.Did == pull.OwnerDid { ··· 198 188 m[p.Sha] = p 199 189 } 200 190 191 + reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 192 + if err != nil { 193 + log.Println("failed to get pull reactions") 194 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 + } 196 + 197 + userReactions := map[db.ReactionKind]bool{} 198 + if user != nil { 199 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 + } 201 + 201 202 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 202 203 LoggedInUser: user, 203 204 RepoInfo: repoInfo, 204 - DidHandleMap: didHandleMap, 205 205 Pull: pull, 206 206 Stack: stack, 207 207 AbandonedPulls: abandonedPulls, 208 208 MergeCheck: mergeCheckResponse, 209 209 ResubmitCheck: resubmitResult, 210 210 Pipelines: m, 211 + 212 + OrderedReactionKinds: db.OrderedReactionKinds, 213 + Reactions: reactionCountMap, 214 + UserReacted: userReactions, 211 215 }) 212 216 } 213 217 ··· 242 246 patch = mergeable.CombinedPatch() 243 247 } 244 248 245 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 249 + resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch) 246 250 if err != nil { 247 251 log.Println("failed to check for mergeability:", err) 248 252 return types.MergeCheckResponse{ ··· 303 307 // pulls within the same repo 304 308 knot = f.Knot 305 309 ownerDid = f.OwnerDid() 306 - repoName = f.RepoName 310 + repoName = f.Name 307 311 } 308 312 309 313 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) ··· 340 344 return 341 345 } 342 346 347 + var diffOpts types.DiffOpts 348 + if d := r.URL.Query().Get("diff"); d == "split" { 349 + diffOpts.Split = true 350 + } 351 + 343 352 pull, ok := r.Context().Value("pull").(*db.Pull) 344 353 if !ok { 345 354 log.Println("failed to get pull") ··· 355 364 http.Error(w, "bad round id", http.StatusBadRequest) 356 365 log.Println("failed to parse round id", err) 357 366 return 358 - } 359 - 360 - identsToResolve := []string{pull.OwnerDid} 361 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 362 - didHandleMap := make(map[string]string) 363 - for _, identity := range resolvedIds { 364 - if !identity.Handle.IsInvalidHandle() { 365 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 366 - } else { 367 - didHandleMap[identity.DID.String()] = identity.DID.String() 368 - } 369 367 } 370 368 371 369 patch := pull.Submissions[roundIdInt].Patch ··· 373 371 374 372 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 375 373 LoggedInUser: user, 376 - DidHandleMap: didHandleMap, 377 374 RepoInfo: f.RepoInfo(user), 378 375 Pull: pull, 379 376 Stack: stack, 380 377 Round: roundIdInt, 381 378 Submission: pull.Submissions[roundIdInt], 382 379 Diff: &diff, 380 + DiffOpts: diffOpts, 383 381 }) 384 382 385 383 } ··· 391 389 if err != nil { 392 390 log.Println("failed to get repo and knot", err) 393 391 return 392 + } 393 + 394 + var diffOpts types.DiffOpts 395 + if d := r.URL.Query().Get("diff"); d == "split" { 396 + diffOpts.Split = true 394 397 } 395 398 396 399 pull, ok := r.Context().Value("pull").(*db.Pull) ··· 414 417 return 415 418 } 416 419 417 - identsToResolve := []string{pull.OwnerDid} 418 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 419 - didHandleMap := make(map[string]string) 420 - for _, identity := range resolvedIds { 421 - if !identity.Handle.IsInvalidHandle() { 422 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 423 - } else { 424 - didHandleMap[identity.DID.String()] = identity.DID.String() 425 - } 426 - } 427 - 428 420 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 429 421 if err != nil { 430 422 log.Println("failed to interdiff; current patch malformed") ··· 446 438 RepoInfo: f.RepoInfo(user), 447 439 Pull: pull, 448 440 Round: roundIdInt, 449 - DidHandleMap: didHandleMap, 450 441 Interdiff: interdiff, 442 + DiffOpts: diffOpts, 451 443 }) 452 - return 453 444 } 454 445 455 446 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 468 459 return 469 460 } 470 461 471 - identsToResolve := []string{pull.OwnerDid} 472 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 473 - didHandleMap := make(map[string]string) 474 - for _, identity := range resolvedIds { 475 - if !identity.Handle.IsInvalidHandle() { 476 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 477 - } else { 478 - didHandleMap[identity.DID.String()] = identity.DID.String() 479 - } 480 - } 481 - 482 462 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 483 463 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 484 464 } ··· 503 483 504 484 pulls, err := db.GetPulls( 505 485 s.db, 506 - db.FilterEq("repo_at", f.RepoAt), 486 + db.FilterEq("repo_at", f.RepoAt()), 507 487 db.FilterEq("state", state), 508 488 ) 509 489 if err != nil { ··· 529 509 530 510 // we want to group all stacked PRs into just one list 531 511 stacks := make(map[string]db.Stack) 512 + var shas []string 532 513 n := 0 533 514 for _, p := range pulls { 515 + // store the sha for later 516 + shas = append(shas, p.LatestSha()) 534 517 // this PR is stacked 535 518 if p.StackId != "" { 536 519 // we have already seen this PR stack ··· 549 532 } 550 533 pulls = pulls[:n] 551 534 552 - identsToResolve := make([]string, len(pulls)) 553 - for i, pull := range pulls { 554 - identsToResolve[i] = pull.OwnerDid 535 + repoInfo := f.RepoInfo(user) 536 + ps, err := db.GetPipelineStatuses( 537 + s.db, 538 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 539 + db.FilterEq("repo_name", repoInfo.Name), 540 + db.FilterEq("knot", repoInfo.Knot), 541 + db.FilterIn("sha", shas), 542 + ) 543 + if err != nil { 544 + log.Printf("failed to fetch pipeline statuses: %s", err) 545 + // non-fatal 555 546 } 556 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 557 - didHandleMap := make(map[string]string) 558 - for _, identity := range resolvedIds { 559 - if !identity.Handle.IsInvalidHandle() { 560 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 561 - } else { 562 - didHandleMap[identity.DID.String()] = identity.DID.String() 563 - } 547 + m := make(map[string]db.Pipeline) 548 + for _, p := range ps { 549 + m[p.Sha] = p 564 550 } 565 551 566 552 s.pages.RepoPulls(w, pages.RepoPullsParams{ 567 553 LoggedInUser: s.oauth.GetUser(r), 568 554 RepoInfo: f.RepoInfo(user), 569 555 Pulls: pulls, 570 - DidHandleMap: didHandleMap, 571 556 FilteringBy: state, 572 557 Stacks: stacks, 558 + Pipelines: m, 573 559 }) 574 - return 575 560 } 576 561 577 562 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 625 610 createdAt := time.Now().Format(time.RFC3339) 626 611 ownerDid := user.Did 627 612 628 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 613 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 629 614 if err != nil { 630 615 log.Println("failed to get pull at", err) 631 616 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 632 617 return 633 618 } 634 619 635 - atUri := f.RepoAt.String() 620 + atUri := f.RepoAt().String() 636 621 client, err := s.oauth.AuthorizedClient(r) 637 622 if err != nil { 638 623 log.Println("failed to get authorized client", err) ··· 642 627 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 628 Collection: tangled.RepoPullCommentNSID, 644 629 Repo: user.Did, 645 - Rkey: appview.TID(), 630 + Rkey: tid.TID(), 646 631 Record: &lexutil.LexiconTypeDecoder{ 647 632 Val: &tangled.RepoPullComment{ 648 633 Repo: &atUri, ··· 659 644 return 660 645 } 661 646 662 - // Create the pull comment in the database with the commentAt field 663 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 647 + comment := &db.PullComment{ 664 648 OwnerDid: user.Did, 665 - RepoAt: f.RepoAt.String(), 649 + RepoAt: f.RepoAt().String(), 666 650 PullId: pull.PullId, 667 651 Body: body, 668 652 CommentAt: atResp.Uri, 669 653 SubmissionId: pull.Submissions[roundNumber].ID, 670 - }) 654 + } 655 + 656 + // Create the pull comment in the database with the commentAt field 657 + commentId, err := db.NewPullComment(tx, comment) 671 658 if err != nil { 672 659 log.Println("failed to create pull comment", err) 673 660 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 681 668 return 682 669 } 683 670 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 - } 671 + s.notifier.NewPullComment(r.Context(), comment) 694 672 695 673 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 696 674 return ··· 714 692 return 715 693 } 716 694 717 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 695 + result, err := us.Branches(f.OwnerDid(), f.Name) 718 696 if err != nil { 719 697 log.Println("failed to fetch branches", err) 720 698 return ··· 762 740 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 763 741 return 764 742 } 743 + sanitizer := markup.NewSanitizer() 744 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 745 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 746 + return 747 + } 765 748 } 766 749 767 750 // Validate we have at least one valid PR creation method ··· 838 821 return 839 822 } 840 823 841 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 824 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 842 825 if err != nil { 843 826 log.Println("failed to compare", err) 844 827 s.pages.Notice(w, "pull", err.Error()) ··· 940 923 return 941 924 } 942 925 943 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 944 - if err != nil { 945 - log.Println("failed to parse fork AT URI", err) 946 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 947 - return 948 - } 926 + forkAtUri := fork.RepoAt() 927 + forkAtUriStr := forkAtUri.String() 949 928 950 929 pullSource := &db.PullSource{ 951 930 Branch: sourceBranch, ··· 953 932 } 954 933 recordPullSource := &tangled.RepoPull_Source{ 955 934 Branch: sourceBranch, 956 - Repo: &fork.AtUri, 935 + Repo: &forkAtUriStr, 957 936 Sha: sourceRev, 958 937 } 959 938 ··· 1019 998 body = formatPatches[0].Body 1020 999 } 1021 1000 1022 - rkey := appview.TID() 1001 + rkey := tid.TID() 1023 1002 initialSubmission := db.PullSubmission{ 1024 1003 Patch: patch, 1025 1004 SourceRev: sourceRev, 1026 1005 } 1027 - err = db.NewPull(tx, &db.Pull{ 1006 + pull := &db.Pull{ 1028 1007 Title: title, 1029 1008 Body: body, 1030 1009 TargetBranch: targetBranch, 1031 1010 OwnerDid: user.Did, 1032 - RepoAt: f.RepoAt, 1011 + RepoAt: f.RepoAt(), 1033 1012 Rkey: rkey, 1034 1013 Submissions: []*db.PullSubmission{ 1035 1014 &initialSubmission, 1036 1015 }, 1037 1016 PullSource: pullSource, 1038 - }) 1017 + } 1018 + err = db.NewPull(tx, pull) 1039 1019 if err != nil { 1040 1020 log.Println("failed to create pull request", err) 1041 1021 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1042 1022 return 1043 1023 } 1044 - pullId, err := db.NextPullId(tx, f.RepoAt) 1024 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1045 1025 if err != nil { 1046 1026 log.Println("failed to get pull id", err) 1047 1027 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1056 1036 Val: &tangled.RepoPull{ 1057 1037 Title: title, 1058 1038 PullId: int64(pullId), 1059 - TargetRepo: string(f.RepoAt), 1039 + TargetRepo: string(f.RepoAt()), 1060 1040 TargetBranch: targetBranch, 1061 1041 Patch: patch, 1062 1042 Source: recordPullSource, ··· 1075 1055 return 1076 1056 } 1077 1057 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 - } 1058 + s.notifier.NewPull(r.Context(), pull) 1088 1059 1089 1060 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1090 1061 } ··· 1243 1214 return 1244 1215 } 1245 1216 1246 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1217 + result, err := us.Branches(f.OwnerDid(), f.Name) 1247 1218 if err != nil { 1248 1219 log.Println("failed to reach knotserver", err) 1249 1220 return ··· 1327 1298 return 1328 1299 } 1329 1300 1330 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1301 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1331 1302 if err != nil { 1332 1303 log.Println("failed to reach knotserver for target branches", err) 1333 1304 return ··· 1443 1414 return 1444 1415 } 1445 1416 1446 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1417 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1447 1418 if err != nil { 1448 1419 log.Printf("compare request failed: %s", err) 1449 1420 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1627 1598 Val: &tangled.RepoPull{ 1628 1599 Title: pull.Title, 1629 1600 PullId: int64(pull.PullId), 1630 - TargetRepo: string(f.RepoAt), 1601 + TargetRepo: string(f.RepoAt()), 1631 1602 TargetBranch: pull.TargetBranch, 1632 1603 Patch: patch, // new patch 1633 1604 Source: recordPullSource, ··· 1647 1618 } 1648 1619 1649 1620 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1650 - return 1651 1621 } 1652 1622 1653 1623 func (s *Pulls) resubmitStackedPullHelper( ··· 1744 1714 1745 1715 // deleted pulls are marked as deleted in the DB 1746 1716 for _, p := range deletions { 1717 + // do not do delete already merged PRs 1718 + if p.State == db.PullMerged { 1719 + continue 1720 + } 1721 + 1747 1722 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1748 1723 if err != nil { 1749 1724 log.Println("failed to delete pull", err, p.PullId) ··· 1784 1759 op, _ := origById[id] 1785 1760 np, _ := newById[id] 1786 1761 1762 + // do not update already merged PRs 1763 + if op.State == db.PullMerged { 1764 + continue 1765 + } 1766 + 1787 1767 submission := np.Submissions[np.LastRoundNumber()] 1788 1768 1789 1769 // resubmit the old pull ··· 1891 1871 } 1892 1872 1893 1873 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1894 - return 1895 1874 } 1896 1875 1897 1876 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 1956 1935 } 1957 1936 1958 1937 // Merge the pull request 1959 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1938 + resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1960 1939 if err != nil { 1961 1940 log.Printf("failed to merge pull request: %s", err) 1962 1941 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1978 1957 defer tx.Rollback() 1979 1958 1980 1959 for _, p := range pullsToMerge { 1981 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1960 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 1982 1961 if err != nil { 1983 1962 log.Printf("failed to update pull request status in database: %s", err) 1984 1963 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1994 1973 return 1995 1974 } 1996 1975 1997 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1976 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 1998 1977 } 1999 1978 2000 1979 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2015 1994 2016 1995 // auth filter: only owner or collaborators can close 2017 1996 roles := f.RolesInRepo(user) 1997 + isOwner := roles.IsOwner() 2018 1998 isCollaborator := roles.IsCollaborator() 2019 1999 isPullAuthor := user.Did == pull.OwnerDid 2020 - isCloseAllowed := isCollaborator || isPullAuthor 2000 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2021 2001 if !isCloseAllowed { 2022 2002 log.Println("failed to close pull") 2023 2003 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2045 2025 2046 2026 for _, p := range pullsToClose { 2047 2027 // Close the pull in the database 2048 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2028 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2049 2029 if err != nil { 2050 2030 log.Println("failed to close pull", err) 2051 2031 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2061 2041 } 2062 2042 2063 2043 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2064 - return 2065 2044 } 2066 2045 2067 2046 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2083 2062 2084 2063 // auth filter: only owner or collaborators can close 2085 2064 roles := f.RolesInRepo(user) 2065 + isOwner := roles.IsOwner() 2086 2066 isCollaborator := roles.IsCollaborator() 2087 2067 isPullAuthor := user.Did == pull.OwnerDid 2088 - isCloseAllowed := isCollaborator || isPullAuthor 2068 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2089 2069 if !isCloseAllowed { 2090 2070 log.Println("failed to close pull") 2091 2071 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2113 2093 2114 2094 for _, p := range pullsToReopen { 2115 2095 // Close the pull in the database 2116 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2096 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2117 2097 if err != nil { 2118 2098 log.Println("failed to close pull", err) 2119 2099 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2129 2109 } 2130 2110 2131 2111 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2132 - return 2133 2112 } 2134 2113 2135 2114 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2155 2134 2156 2135 title := fp.Title 2157 2136 body := fp.Body 2158 - rkey := appview.TID() 2137 + rkey := tid.TID() 2159 2138 2160 2139 initialSubmission := db.PullSubmission{ 2161 2140 Patch: fp.Raw, ··· 2166 2145 Body: body, 2167 2146 TargetBranch: targetBranch, 2168 2147 OwnerDid: user.Did, 2169 - RepoAt: f.RepoAt, 2148 + RepoAt: f.RepoAt(), 2170 2149 Rkey: rkey, 2171 2150 Submissions: []*db.PullSubmission{ 2172 2151 &initialSubmission,
+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
+8 -8
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{ ··· 76 76 Artifact: uploadBlobResp.Blob, 77 77 CreatedAt: createdAt.Format(time.RFC3339), 78 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 79 + Repo: f.RepoAt().String(), 80 80 Tag: tag.Tag.Hash[:], 81 81 }, 82 82 }, ··· 100 100 artifact := db.Artifact{ 101 101 Did: user.Did, 102 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 103 + RepoAt: f.RepoAt(), 104 104 Tag: tag.Tag.Hash, 105 105 CreatedAt: createdAt, 106 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 155 156 156 artifacts, err := db.GetArtifact( 157 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 158 + db.FilterEq("repo_at", f.RepoAt()), 159 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 160 db.FilterEq("name", filename), 161 161 ) ··· 197 197 198 198 artifacts, err := db.GetArtifact( 199 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 200 + db.FilterEq("repo_at", f.RepoAt()), 201 201 db.FilterEq("tag", tag[:]), 202 202 db.FilterEq("name", filename), 203 203 ) ··· 239 239 defer tx.Rollback() 240 240 241 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 242 + db.FilterEq("repo_at", f.RepoAt()), 243 243 db.FilterEq("tag", artifact.Tag[:]), 244 244 db.FilterEq("name", filename), 245 245 ) ··· 270 270 return nil, err 271 271 } 272 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 + result, err := us.Tags(f.OwnerDid(), f.Name) 274 274 if err != nil { 275 275 log.Println("failed to reach knotserver", err) 276 276 return nil, err
+165
appview/repo/feed.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/reporesolver" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/feeds" 16 + ) 17 + 18 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 19 + const feedLimitPerType = 100 20 + 21 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + feed := &feeds.Feed{ 32 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 33 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 34 + Items: make([]*feeds.Item, 0), 35 + Updated: time.UnixMilli(0), 36 + } 37 + 38 + for _, pull := range pulls { 39 + items, err := rp.createPullItems(ctx, pull, f) 40 + if err != nil { 41 + return nil, err 42 + } 43 + feed.Items = append(feed.Items, items...) 44 + } 45 + 46 + for _, issue := range issues { 47 + item, err := rp.createIssueItem(ctx, issue, f) 48 + if err != nil { 49 + return nil, err 50 + } 51 + feed.Items = append(feed.Items, item) 52 + } 53 + 54 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 55 + if a.Created.After(b.Created) { 56 + return -1 57 + } 58 + return 1 59 + }) 60 + 61 + if len(feed.Items) > 0 { 62 + feed.Updated = feed.Items[0].Created 63 + } 64 + 65 + return feed, nil 66 + } 67 + 68 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + var items []*feeds.Item 75 + 76 + state := rp.getPullState(pull) 77 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 78 + 79 + mainItem := &feeds.Item{ 80 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 81 + Description: description, 82 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 83 + Created: pull.Created, 84 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 85 + } 86 + items = append(items, mainItem) 87 + 88 + for _, round := range pull.Submissions { 89 + if round == nil || round.RoundNumber == 0 { 90 + continue 91 + } 92 + 93 + roundItem := &feeds.Item{ 94 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 95 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 96 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 97 + Created: round.Created, 98 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 99 + } 100 + items = append(items, roundItem) 101 + } 102 + 103 + return items, nil 104 + } 105 + 106 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + state := "closed" 113 + if issue.Open { 114 + state = "opened" 115 + } 116 + 117 + return &feeds.Item{ 118 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 119 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 120 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 121 + Created: issue.Created, 122 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 123 + }, nil 124 + } 125 + 126 + func (rp *Repo) getPullState(pull *db.Pull) string { 127 + if pull.State == db.PullOpen { 128 + return "opened" 129 + } 130 + return pull.State.String() 131 + } 132 + 133 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 134 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 + 136 + if pull.State == db.PullMerged { 137 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 + } 139 + 140 + return fmt.Sprintf("%s in %s", base, repoName) 141 + } 142 + 143 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 144 + f, err := rp.repoResolver.Resolve(r) 145 + if err != nil { 146 + log.Println("failed to fully resolve repo:", err) 147 + return 148 + } 149 + 150 + feed, err := rp.getRepoFeed(r.Context(), f) 151 + if err != nil { 152 + log.Println("failed to get repo feed:", err) 153 + rp.pages.Error500(w) 154 + return 155 + } 156 + 157 + atom, err := feed.ToAtom() 158 + if err != nil { 159 + rp.pages.Error500(w) 160 + return 161 + } 162 + 163 + w.Header().Set("content-type", "application/atom+xml") 164 + w.Write([]byte(atom)) 165 + }
+58 -31
appview/repo/index.go
··· 24 24 25 25 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 26 ref := chi.URLParam(r, "ref") 27 + 27 28 f, err := rp.repoResolver.Resolve(r) 28 29 if err != nil { 29 30 log.Println("failed to fully resolve repo", err) ··· 37 38 return 38 39 } 39 40 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 + result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 42 if err != nil { 42 43 rp.pages.Error503(w) 43 44 log.Println("failed to reach knotserver", err) ··· 57 58 hash := branch.Hash 58 59 tagMap[hash] = append(tagMap[hash], branch.Name) 59 60 } 61 + 62 + sortFiles(result.Files) 60 63 61 64 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 62 65 if a.Name == result.Ref { ··· 116 119 117 120 var forkInfo *types.ForkInfo 118 121 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 119 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 + forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient) 120 123 if err != nil { 121 124 log.Printf("Failed to fetch fork information: %v", err) 122 125 return 123 126 } 124 127 } 125 128 126 - languageInfo, err := getLanguageInfo(f, signedClient, ref) 129 + // TODO: a bit dirty 130 + languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "") 127 131 if err != nil { 128 132 log.Printf("failed to compute language percentages: %s", err) 129 133 // non-fatal ··· 153 157 Languages: languageInfo, 154 158 Pipelines: pipelines, 155 159 }) 156 - return 157 160 } 158 161 159 - func getLanguageInfo( 162 + func (rp *Repo) getLanguageInfo( 160 163 f *reporesolver.ResolvedRepo, 161 164 signedClient *knotclient.SignedClient, 162 - ref string, 165 + currentRef string, 166 + isDefaultRef bool, 163 167 ) ([]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 - } 168 + // first attempt to fetch from db 169 + langs, err := db.GetRepoLanguages( 170 + rp.db, 171 + db.FilterEq("repo_at", f.RepoAt()), 172 + db.FilterEq("ref", currentRef), 173 + ) 171 174 172 - var totalSize int64 173 - for _, fileSize := range repoLanguages.Languages { 174 - totalSize += fileSize 175 - } 175 + if err != nil || langs == nil { 176 + // non-fatal, fetch langs from ks 177 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 178 + if err != nil { 179 + return nil, err 180 + } 181 + if ls == nil { 182 + return nil, nil 183 + } 176 184 177 - var languageStats []types.RepoLanguageDetails 178 - var otherPercentage float32 = 0 179 - 180 - for lang, size := range repoLanguages.Languages { 181 - percentage := (float32(size) / float32(totalSize)) * 100 185 + for l, s := range ls.Languages { 186 + langs = append(langs, db.RepoLanguage{ 187 + RepoAt: f.RepoAt(), 188 + Ref: currentRef, 189 + IsDefaultRef: isDefaultRef, 190 + Language: l, 191 + Bytes: s, 192 + }) 193 + } 182 194 183 - if percentage <= 0.5 { 184 - otherPercentage += percentage 185 - continue 195 + // update appview's cache 196 + err = db.InsertRepoLanguages(rp.db, langs) 197 + if err != nil { 198 + // non-fatal 199 + log.Println("failed to cache lang results", err) 186 200 } 201 + } 187 202 188 - color := enry.GetColor(lang) 203 + var total int64 204 + for _, l := range langs { 205 + total += l.Bytes 206 + } 189 207 190 - languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color}) 208 + var languageStats []types.RepoLanguageDetails 209 + for _, l := range langs { 210 + percentage := float32(l.Bytes) / float32(total) * 100 211 + color := enry.GetColor(l.Language) 212 + languageStats = append(languageStats, types.RepoLanguageDetails{ 213 + Name: l.Language, 214 + Percentage: percentage, 215 + Color: color, 216 + }) 191 217 } 192 218 193 219 sort.Slice(languageStats, func(i, j int) bool { ··· 210 236 repoInfo repoinfo.RepoInfo, 211 237 rp *Repo, 212 238 f *reporesolver.ResolvedRepo, 239 + currentRef string, 213 240 user *oauth.User, 214 241 signedClient *knotclient.SignedClient, 215 242 ) (*types.ForkInfo, error) { ··· 240 267 } 241 268 242 269 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 243 - return branch.Name == f.Ref 270 + return branch.Name == currentRef 244 271 }) { 245 272 forkInfo.Status = types.MissingBranch 246 273 return &forkInfo, nil 247 274 } 248 275 249 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 276 + newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef) 250 277 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 251 278 log.Printf("failed to update tracking branch: %s", err) 252 279 return nil, err 253 280 } 254 281 255 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 282 + hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef) 256 283 257 284 var status types.AncestorCheckResponse 258 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 285 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef) 259 286 if err != nil { 260 287 log.Printf("failed to check if fork is ahead/behind: %s", err) 261 288 return nil, err
+539 -175
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 84 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 85 + refParam := chi.URLParam(r, "ref") 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + log.Println("failed to get repo and knot", err) 89 + return 90 + } 91 + 92 + var uri string 93 + if rp.config.Core.Dev { 94 + uri = "http" 95 + } else { 96 + uri = "https" 97 + } 98 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 99 + 100 + http.Redirect(w, r, url, http.StatusFound) 101 + } 102 + 80 103 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 81 104 f, err := rp.repoResolver.Resolve(r) 82 105 if err != nil { ··· 100 123 return 101 124 } 102 125 103 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 126 + repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 104 127 if err != nil { 105 128 log.Println("failed to reach knotserver", err) 106 129 return 107 130 } 108 131 109 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 132 + tagResult, err := us.Tags(f.OwnerDid(), f.Name) 110 133 if err != nil { 111 134 log.Println("failed to reach knotserver", err) 112 135 return 113 136 } 114 137 115 138 tagMap := make(map[string][]string) 116 - for _, tag := range result.Tags { 139 + for _, tag := range tagResult.Tags { 117 140 hash := tag.Hash 118 141 if tag.Tag != nil { 119 142 hash = tag.Tag.Target.String() ··· 121 144 tagMap[hash] = append(tagMap[hash], tag.Name) 122 145 } 123 146 147 + branchResult, err := us.Branches(f.OwnerDid(), f.Name) 148 + if err != nil { 149 + log.Println("failed to reach knotserver", err) 150 + return 151 + } 152 + 153 + for _, branch := range branchResult.Branches { 154 + hash := branch.Hash 155 + tagMap[hash] = append(tagMap[hash], branch.Name) 156 + } 157 + 124 158 user := rp.oauth.GetUser(r) 125 159 126 160 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) ··· 154 188 VerifiedCommits: vc, 155 189 Pipelines: pipelines, 156 190 }) 157 - return 158 191 } 159 192 160 193 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { ··· 169 202 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 170 203 RepoInfo: f.RepoInfo(user), 171 204 }) 172 - return 173 205 } 174 206 175 207 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 180 212 return 181 213 } 182 214 183 - repoAt := f.RepoAt 215 + repoAt := f.RepoAt() 184 216 rkey := repoAt.RecordKey().String() 185 217 if rkey == "" { 186 218 log.Println("invalid aturi for repo", err) ··· 230 262 Record: &lexutil.LexiconTypeDecoder{ 231 263 Val: &tangled.Repo{ 232 264 Knot: f.Knot, 233 - Name: f.RepoName, 265 + Name: f.Name, 234 266 Owner: user.Did, 235 - CreatedAt: f.CreatedAt, 267 + CreatedAt: f.Created.Format(time.RFC3339), 236 268 Description: &newDescription, 237 269 Spindle: &f.Spindle, 238 270 }, ··· 268 300 protocol = "https" 269 301 } 270 302 303 + var diffOpts types.DiffOpts 304 + if d := r.URL.Query().Get("diff"); d == "split" { 305 + diffOpts.Split = true 306 + } 307 + 271 308 if !plumbing.IsHash(ref) { 272 309 rp.pages.Error404(w) 273 310 return 274 311 } 275 312 276 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 313 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 277 314 if err != nil { 278 315 log.Println("failed to reach knotserver", err) 279 316 return ··· 321 358 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 322 359 VerifiedCommit: vc, 323 360 Pipeline: pipeline, 361 + DiffOpts: diffOpts, 324 362 }) 325 - return 326 363 } 327 364 328 365 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 338 375 if !rp.config.Core.Dev { 339 376 protocol = "https" 340 377 } 341 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 378 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 342 379 if err != nil { 343 380 log.Println("failed to reach knotserver", err) 344 381 return ··· 359 396 360 397 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 361 398 // so we can safely redirect to the "parent" (which is the same file). 362 - if len(result.Files) == 0 && result.Parent == treePath { 399 + unescapedTreePath, _ := url.PathUnescape(treePath) 400 + if len(result.Files) == 0 && result.Parent == unescapedTreePath { 363 401 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 364 402 return 365 403 } ··· 367 405 user := rp.oauth.GetUser(r) 368 406 369 407 var breadcrumbs [][]string 370 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 408 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 371 409 if treePath != "" { 372 410 for idx, elem := range strings.Split(treePath, "/") { 373 411 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 374 412 } 375 413 } 376 414 377 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 378 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 415 + sortFiles(result.Files) 379 416 380 417 rp.pages.RepoTree(w, pages.RepoTreeParams{ 381 418 LoggedInUser: user, 382 419 BreadCrumbs: breadcrumbs, 383 - BaseTreeLink: baseTreeLink, 384 - BaseBlobLink: baseBlobLink, 420 + TreePath: treePath, 385 421 RepoInfo: f.RepoInfo(user), 386 422 RepoTreeResponse: result, 387 423 }) 388 - return 389 424 } 390 425 391 426 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 401 436 return 402 437 } 403 438 404 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 439 + result, err := us.Tags(f.OwnerDid(), f.Name) 405 440 if err != nil { 406 441 log.Println("failed to reach knotserver", err) 407 442 return 408 443 } 409 444 410 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 445 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 411 446 if err != nil { 412 447 log.Println("failed grab artifacts", err) 413 448 return ··· 443 478 ArtifactMap: artifactMap, 444 479 DanglingArtifacts: danglingArtifacts, 445 480 }) 446 - return 447 481 } 448 482 449 483 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 459 493 return 460 494 } 461 495 462 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 496 + result, err := us.Branches(f.OwnerDid(), f.Name) 463 497 if err != nil { 464 498 log.Println("failed to reach knotserver", err) 465 499 return 466 500 } 467 501 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 - }) 502 + sortBranches(result.Branches) 484 503 485 504 user := rp.oauth.GetUser(r) 486 505 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 488 507 RepoInfo: f.RepoInfo(user), 489 508 RepoBranchesResponse: *result, 490 509 }) 491 - return 492 510 } 493 511 494 512 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 504 522 if !rp.config.Core.Dev { 505 523 protocol = "https" 506 524 } 507 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 525 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 508 526 if err != nil { 509 527 log.Println("failed to reach knotserver", err) 510 528 return ··· 524 542 } 525 543 526 544 var breadcrumbs [][]string 527 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 545 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 528 546 if filePath != "" { 529 547 for idx, elem := range strings.Split(filePath, "/") { 530 548 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 539 557 showRendered = r.URL.Query().Get("code") != "true" 540 558 } 541 559 560 + var unsupported bool 561 + var isImage bool 562 + var isVideo bool 563 + var contentSrc string 564 + 565 + if result.IsBinary { 566 + ext := strings.ToLower(filepath.Ext(result.Path)) 567 + switch ext { 568 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 569 + isImage = true 570 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 571 + isVideo = true 572 + default: 573 + unsupported = true 574 + } 575 + 576 + // fetch the actual binary content like in RepoBlobRaw 577 + 578 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 579 + contentSrc = blobURL 580 + if !rp.config.Core.Dev { 581 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 582 + } 583 + } 584 + 542 585 user := rp.oauth.GetUser(r) 543 586 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 544 587 LoggedInUser: user, ··· 547 590 BreadCrumbs: breadcrumbs, 548 591 ShowRendered: showRendered, 549 592 RenderToggle: renderToggle, 593 + Unsupported: unsupported, 594 + IsImage: isImage, 595 + IsVideo: isVideo, 596 + ContentSrc: contentSrc, 550 597 }) 551 - return 552 598 } 553 599 554 600 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 555 601 f, err := rp.repoResolver.Resolve(r) 556 602 if err != nil { 557 603 log.Println("failed to get repo and knot", err) 604 + w.WriteHeader(http.StatusBadRequest) 558 605 return 559 606 } 560 607 ··· 565 612 if !rp.config.Core.Dev { 566 613 protocol = "https" 567 614 } 568 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 615 + 616 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 617 + 618 + req, err := http.NewRequest("GET", blobURL, nil) 569 619 if err != nil { 570 - log.Println("failed to reach knotserver", err) 620 + log.Println("failed to create request", err) 571 621 return 572 622 } 573 623 574 - body, err := io.ReadAll(resp.Body) 624 + // forward the If-None-Match header 625 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 626 + req.Header.Set("If-None-Match", clientETag) 627 + } 628 + 629 + client := &http.Client{} 630 + resp, err := client.Do(req) 575 631 if err != nil { 576 - log.Printf("Error reading response body: %v", err) 632 + log.Println("failed to reach knotserver", err) 633 + rp.pages.Error503(w) 634 + return 635 + } 636 + defer resp.Body.Close() 637 + 638 + // forward 304 not modified 639 + if resp.StatusCode == http.StatusNotModified { 640 + w.WriteHeader(http.StatusNotModified) 641 + return 642 + } 643 + 644 + if resp.StatusCode != http.StatusOK { 645 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 646 + w.WriteHeader(resp.StatusCode) 647 + _, _ = io.Copy(w, resp.Body) 577 648 return 578 649 } 579 650 580 - var result types.RepoBlobResponse 581 - err = json.Unmarshal(body, &result) 651 + contentType := resp.Header.Get("Content-Type") 652 + body, err := io.ReadAll(resp.Body) 582 653 if err != nil { 583 - log.Println("failed to parse response:", err) 654 + log.Printf("error reading response body from knotserver: %v", err) 655 + w.WriteHeader(http.StatusInternalServerError) 584 656 return 585 657 } 586 658 587 - if result.IsBinary { 588 - w.Header().Set("Content-Type", "application/octet-stream") 659 + if strings.Contains(contentType, "text/plain") { 660 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 661 + w.Write(body) 662 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 663 + w.Header().Set("Content-Type", contentType) 589 664 w.Write(body) 665 + } else { 666 + w.WriteHeader(http.StatusUnsupportedMediaType) 667 + w.Write([]byte("unsupported content type")) 590 668 return 591 669 } 592 - 593 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 594 - w.Write([]byte(result.Contents)) 595 - return 596 670 } 597 671 598 672 // modify the spindle configured for this repo 599 673 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 674 + user := rp.oauth.GetUser(r) 675 + l := rp.logger.With("handler", "EditSpindle") 676 + l = l.With("did", user.Did) 677 + l = l.With("handle", user.Handle) 678 + 679 + errorId := "operation-error" 680 + fail := func(msg string, err error) { 681 + l.Error(msg, "err", err) 682 + rp.pages.Notice(w, errorId, msg) 683 + } 684 + 600 685 f, err := rp.repoResolver.Resolve(r) 601 686 if err != nil { 602 - log.Println("failed to get repo and knot", err) 603 - w.WriteHeader(http.StatusBadRequest) 687 + fail("Failed to resolve repo. Try again later", err) 604 688 return 605 689 } 606 690 607 - repoAt := f.RepoAt 691 + repoAt := f.RepoAt() 608 692 rkey := repoAt.RecordKey().String() 609 693 if rkey == "" { 610 - log.Println("invalid aturi for repo", err) 611 - w.WriteHeader(http.StatusInternalServerError) 694 + fail("Failed to resolve repo. Try again later", err) 612 695 return 613 696 } 614 697 615 - user := rp.oauth.GetUser(r) 616 - 617 698 newSpindle := r.FormValue("spindle") 699 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 618 700 client, err := rp.oauth.AuthorizedClient(r) 619 701 if err != nil { 620 - log.Println("failed to get client") 621 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 702 + fail("Failed to authorize. Try again later.", err) 622 703 return 623 704 } 624 705 625 - // ensure that this is a valid spindle for this user 626 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 627 - if err != nil { 628 - log.Println("failed to get valid spindles") 629 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 630 - return 706 + if !removingSpindle { 707 + // ensure that this is a valid spindle for this user 708 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 709 + if err != nil { 710 + fail("Failed to find spindles. Try again later.", err) 711 + return 712 + } 713 + 714 + if !slices.Contains(validSpindles, newSpindle) { 715 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 716 + return 717 + } 631 718 } 632 719 633 - if !slices.Contains(validSpindles, newSpindle) { 634 - log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles) 635 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 636 - return 720 + spindlePtr := &newSpindle 721 + if removingSpindle { 722 + spindlePtr = nil 637 723 } 638 724 639 725 // optimistic update 640 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 726 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 641 727 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.") 728 + fail("Failed to update spindle. Try again later.", err) 644 729 return 645 730 } 646 731 647 732 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 648 733 if err != nil { 649 - // failed to get record 650 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 734 + fail("Failed to update spindle, no record found on PDS.", err) 651 735 return 652 736 } 653 737 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 658 742 Record: &lexutil.LexiconTypeDecoder{ 659 743 Val: &tangled.Repo{ 660 744 Knot: f.Knot, 661 - Name: f.RepoName, 745 + Name: f.Name, 662 746 Owner: user.Did, 663 - CreatedAt: f.CreatedAt, 747 + CreatedAt: f.Created.Format(time.RFC3339), 664 748 Description: &f.Description, 665 - Spindle: &newSpindle, 749 + Spindle: spindlePtr, 666 750 }, 667 751 }, 668 752 }) 669 753 670 754 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.") 755 + fail("Failed to update spindle, unable to save to PDS.", err) 674 756 return 675 757 } 676 758 677 - // add this spindle to spindle stream 678 - rp.spindlestream.AddSource( 679 - context.Background(), 680 - eventconsumer.NewSpindleSource(newSpindle), 681 - ) 759 + if !removingSpindle { 760 + // add this spindle to spindle stream 761 + rp.spindlestream.AddSource( 762 + context.Background(), 763 + eventconsumer.NewSpindleSource(newSpindle), 764 + ) 765 + } 682 766 683 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 767 + rp.pages.HxRefresh(w) 684 768 } 685 769 686 770 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 771 + user := rp.oauth.GetUser(r) 772 + l := rp.logger.With("handler", "AddCollaborator") 773 + l = l.With("did", user.Did) 774 + l = l.With("handle", user.Handle) 775 + 687 776 f, err := rp.repoResolver.Resolve(r) 688 777 if err != nil { 689 - log.Println("failed to get repo and knot", err) 778 + l.Error("failed to get repo and knot", "err", err) 690 779 return 691 780 } 692 781 782 + errorId := "add-collaborator-error" 783 + fail := func(msg string, err error) { 784 + l.Error(msg, "err", err) 785 + rp.pages.Notice(w, errorId, msg) 786 + } 787 + 693 788 collaborator := r.FormValue("collaborator") 694 789 if collaborator == "" { 695 - http.Error(w, "malformed form", http.StatusBadRequest) 790 + fail("Invalid form.", nil) 696 791 return 697 792 } 698 793 794 + // remove a single leading `@`, to make @handle work with ResolveIdent 795 + collaborator = strings.TrimPrefix(collaborator, "@") 796 + 699 797 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 700 798 if err != nil { 701 - w.Write([]byte("failed to resolve collaborator did to a handle")) 799 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 800 + return 801 + } 802 + 803 + if collaboratorIdent.DID.String() == user.Did { 804 + fail("You seem to be adding yourself as a collaborator.", nil) 805 + return 806 + } 807 + l = l.With("collaborator", collaboratorIdent.Handle) 808 + l = l.With("knot", f.Knot) 809 + 810 + // announce this relation into the firehose, store into owners' pds 811 + client, err := rp.oauth.AuthorizedClient(r) 812 + if err != nil { 813 + fail("Failed to write to PDS.", err) 702 814 return 703 815 } 704 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 705 816 706 - // TODO: create an atproto record for this 817 + // emit a record 818 + currentUser := rp.oauth.GetUser(r) 819 + rkey := tid.TID() 820 + createdAt := time.Now() 821 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 822 + Collection: tangled.RepoCollaboratorNSID, 823 + Repo: currentUser.Did, 824 + Rkey: rkey, 825 + Record: &lexutil.LexiconTypeDecoder{ 826 + Val: &tangled.RepoCollaborator{ 827 + Subject: collaboratorIdent.DID.String(), 828 + Repo: string(f.RepoAt()), 829 + CreatedAt: createdAt.Format(time.RFC3339), 830 + }}, 831 + }) 832 + // invalid record 833 + if err != nil { 834 + fail("Failed to write record to PDS.", err) 835 + return 836 + } 837 + l = l.With("at-uri", resp.Uri) 838 + l.Info("wrote record to PDS") 707 839 840 + l.Info("adding to knot") 708 841 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 709 842 if err != nil { 710 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 843 + fail("Failed to add to knot.", err) 711 844 return 712 845 } 713 846 714 847 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 715 848 if err != nil { 716 - log.Println("failed to create client to ", f.Knot) 849 + fail("Failed to add to knot.", err) 717 850 return 718 851 } 719 852 720 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 853 + ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 721 854 if err != nil { 722 - log.Printf("failed to make request to %s: %s", f.Knot, err) 855 + fail("Knot was unreachable.", err) 723 856 return 724 857 } 725 858 726 859 if ksResp.StatusCode != http.StatusNoContent { 727 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 860 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 728 861 return 729 862 } 730 863 731 864 tx, err := rp.db.BeginTx(r.Context(), nil) 732 865 if err != nil { 733 - log.Println("failed to start tx") 734 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 866 + fail("Failed to add collaborator.", err) 735 867 return 736 868 } 737 869 defer func() { 738 870 tx.Rollback() 739 871 err = rp.enforcer.E.LoadPolicy() 740 872 if err != nil { 741 - log.Println("failed to rollback policies") 873 + fail("Failed to add collaborator.", err) 742 874 } 743 875 }() 744 876 745 877 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 746 878 if err != nil { 747 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 879 + fail("Failed to add collaborator permissions.", err) 748 880 return 749 881 } 750 882 751 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 883 + err = db.AddCollaborator(rp.db, db.Collaborator{ 884 + Did: syntax.DID(currentUser.Did), 885 + Rkey: rkey, 886 + SubjectDid: collaboratorIdent.DID, 887 + RepoAt: f.RepoAt(), 888 + Created: createdAt, 889 + }) 752 890 if err != nil { 753 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 891 + fail("Failed to add collaborator.", err) 754 892 return 755 893 } 756 894 757 895 err = tx.Commit() 758 896 if err != nil { 759 - log.Println("failed to commit changes", err) 760 - http.Error(w, err.Error(), http.StatusInternalServerError) 897 + fail("Failed to add collaborator.", err) 761 898 return 762 899 } 763 900 764 901 err = rp.enforcer.E.SavePolicy() 765 902 if err != nil { 766 - log.Println("failed to update ACLs", err) 767 - http.Error(w, err.Error(), http.StatusInternalServerError) 903 + fail("Failed to update collaborator permissions.", err) 768 904 return 769 905 } 770 906 771 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 772 - 907 + rp.pages.HxRefresh(w) 773 908 } 774 909 775 910 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 787 922 log.Println("failed to get authorized client", err) 788 923 return 789 924 } 790 - repoRkey := f.RepoAt.RecordKey().String() 791 925 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 792 926 Collection: tangled.RepoNSID, 793 927 Repo: user.Did, 794 - Rkey: repoRkey, 928 + Rkey: f.Rkey, 795 929 }) 796 930 if err != nil { 797 931 log.Printf("failed to delete record: %s", err) 798 932 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 799 933 return 800 934 } 801 - log.Println("removed repo record ", f.RepoAt.String()) 935 + log.Println("removed repo record ", f.RepoAt().String()) 802 936 803 937 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 804 938 if err != nil { ··· 812 946 return 813 947 } 814 948 815 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 949 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 816 950 if err != nil { 817 951 log.Printf("failed to make request to %s: %s", f.Knot, err) 818 952 return ··· 858 992 } 859 993 860 994 // remove repo from db 861 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 995 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 862 996 if err != nil { 863 997 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 864 998 return ··· 907 1041 return 908 1042 } 909 1043 910 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1044 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 911 1045 if err != nil { 912 1046 log.Printf("failed to make request to %s: %s", f.Knot, err) 913 1047 return ··· 921 1055 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 922 1056 } 923 1057 924 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1058 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1059 + user := rp.oauth.GetUser(r) 1060 + l := rp.logger.With("handler", "Secrets") 1061 + l = l.With("handle", user.Handle) 1062 + l = l.With("did", user.Did) 1063 + 925 1064 f, err := rp.repoResolver.Resolve(r) 926 1065 if err != nil { 927 1066 log.Println("failed to get repo and knot", err) 928 1067 return 929 1068 } 930 1069 1070 + if f.Spindle == "" { 1071 + log.Println("empty spindle cannot add/rm secret", err) 1072 + return 1073 + } 1074 + 1075 + lxm := tangled.RepoAddSecretNSID 1076 + if r.Method == http.MethodDelete { 1077 + lxm = tangled.RepoRemoveSecretNSID 1078 + } 1079 + 1080 + spindleClient, err := rp.oauth.ServiceClient( 1081 + r, 1082 + oauth.WithService(f.Spindle), 1083 + oauth.WithLxm(lxm), 1084 + oauth.WithExp(60), 1085 + oauth.WithDev(rp.config.Core.Dev), 1086 + ) 1087 + if err != nil { 1088 + log.Println("failed to create spindle client", err) 1089 + return 1090 + } 1091 + 1092 + key := r.FormValue("key") 1093 + if key == "" { 1094 + w.WriteHeader(http.StatusBadRequest) 1095 + return 1096 + } 1097 + 931 1098 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 - } 1099 + case http.MethodPut: 1100 + errorId := "add-secret-error" 939 1101 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 - } 1102 + value := r.FormValue("value") 1103 + if value == "" { 1104 + w.WriteHeader(http.StatusBadRequest) 1105 + return 946 1106 } 947 1107 948 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1108 + err = tangled.RepoAddSecret( 1109 + r.Context(), 1110 + spindleClient, 1111 + &tangled.RepoAddSecret_Input{ 1112 + Repo: f.RepoAt().String(), 1113 + Key: key, 1114 + Value: value, 1115 + }, 1116 + ) 949 1117 if err != nil { 950 - log.Println("failed to create unsigned client", err) 1118 + l.Error("Failed to add secret.", "err", err) 1119 + rp.pages.Notice(w, errorId, "Failed to add secret.") 951 1120 return 952 1121 } 953 1122 954 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1123 + case http.MethodDelete: 1124 + errorId := "operation-error" 1125 + 1126 + err = tangled.RepoRemoveSecret( 1127 + r.Context(), 1128 + spindleClient, 1129 + &tangled.RepoRemoveSecret_Input{ 1130 + Repo: f.RepoAt().String(), 1131 + Key: key, 1132 + }, 1133 + ) 955 1134 if err != nil { 956 - log.Println("failed to reach knotserver", err) 1135 + l.Error("Failed to delete secret.", "err", err) 1136 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 957 1137 return 958 1138 } 1139 + } 959 1140 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 1141 + rp.pages.HxRefresh(w) 1142 + } 1143 + 1144 + type tab = map[string]any 1145 + 1146 + var ( 1147 + // would be great to have ordered maps right about now 1148 + settingsTabs []tab = []tab{ 1149 + {"Name": "general", "Icon": "sliders-horizontal"}, 1150 + {"Name": "access", "Icon": "users"}, 1151 + {"Name": "pipelines", "Icon": "layers-2"}, 1152 + } 1153 + ) 1154 + 1155 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1156 + tabVal := r.URL.Query().Get("tab") 1157 + if tabVal == "" { 1158 + tabVal = "general" 1159 + } 1160 + 1161 + switch tabVal { 1162 + case "general": 1163 + rp.generalSettings(w, r) 1164 + 1165 + case "access": 1166 + rp.accessSettings(w, r) 1167 + 1168 + case "pipelines": 1169 + rp.pipelineSettings(w, r) 1170 + } 1171 + 1172 + // user := rp.oauth.GetUser(r) 1173 + // repoCollaborators, err := f.Collaborators(r.Context()) 1174 + // if err != nil { 1175 + // log.Println("failed to get collaborators", err) 1176 + // } 1177 + 1178 + // isCollaboratorInviteAllowed := false 1179 + // if user != nil { 1180 + // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1181 + // if err == nil && ok { 1182 + // isCollaboratorInviteAllowed = true 1183 + // } 1184 + // } 1185 + 1186 + // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1187 + // if err != nil { 1188 + // log.Println("failed to create unsigned client", err) 1189 + // return 1190 + // } 1191 + 1192 + // result, err := us.Branches(f.OwnerDid(), f.Name) 1193 + // if err != nil { 1194 + // log.Println("failed to reach knotserver", err) 1195 + // return 1196 + // } 1197 + 1198 + // // all spindles that this user is a member of 1199 + // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1200 + // if err != nil { 1201 + // log.Println("failed to fetch spindles", err) 1202 + // return 1203 + // } 1204 + 1205 + // var secrets []*tangled.RepoListSecrets_Secret 1206 + // if f.Spindle != "" { 1207 + // if spindleClient, err := rp.oauth.ServiceClient( 1208 + // r, 1209 + // oauth.WithService(f.Spindle), 1210 + // oauth.WithLxm(tangled.RepoListSecretsNSID), 1211 + // oauth.WithDev(rp.config.Core.Dev), 1212 + // ); err != nil { 1213 + // log.Println("failed to create spindle client", err) 1214 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1215 + // log.Println("failed to fetch secrets", err) 1216 + // } else { 1217 + // secrets = resp.Secrets 1218 + // } 1219 + // } 1220 + 1221 + // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1222 + // LoggedInUser: user, 1223 + // RepoInfo: f.RepoInfo(user), 1224 + // Collaborators: repoCollaborators, 1225 + // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1226 + // Branches: result.Branches, 1227 + // Spindles: spindles, 1228 + // CurrentSpindle: f.Spindle, 1229 + // Secrets: secrets, 1230 + // }) 1231 + } 1232 + 1233 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1234 + f, err := rp.repoResolver.Resolve(r) 1235 + user := rp.oauth.GetUser(r) 1236 + 1237 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1238 + if err != nil { 1239 + log.Println("failed to create unsigned client", err) 1240 + return 1241 + } 1242 + 1243 + result, err := us.Branches(f.OwnerDid(), f.Name) 1244 + if err != nil { 1245 + log.Println("failed to reach knotserver", err) 1246 + return 1247 + } 1248 + 1249 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1250 + LoggedInUser: user, 1251 + RepoInfo: f.RepoInfo(user), 1252 + Branches: result.Branches, 1253 + Tabs: settingsTabs, 1254 + Tab: "general", 1255 + }) 1256 + } 1257 + 1258 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1259 + f, err := rp.repoResolver.Resolve(r) 1260 + user := rp.oauth.GetUser(r) 1261 + 1262 + repoCollaborators, err := f.Collaborators(r.Context()) 1263 + if err != nil { 1264 + log.Println("failed to get collaborators", err) 1265 + } 1266 + 1267 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1268 + LoggedInUser: user, 1269 + RepoInfo: f.RepoInfo(user), 1270 + Tabs: settingsTabs, 1271 + Tab: "access", 1272 + Collaborators: repoCollaborators, 1273 + }) 1274 + } 1275 + 1276 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1277 + f, err := rp.repoResolver.Resolve(r) 1278 + user := rp.oauth.GetUser(r) 1279 + 1280 + // all spindles that the repo owner is a member of 1281 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1282 + if err != nil { 1283 + log.Println("failed to fetch spindles", err) 1284 + return 1285 + } 1286 + 1287 + var secrets []*tangled.RepoListSecrets_Secret 1288 + if f.Spindle != "" { 1289 + if spindleClient, err := rp.oauth.ServiceClient( 1290 + r, 1291 + oauth.WithService(f.Spindle), 1292 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1293 + oauth.WithExp(60), 1294 + oauth.WithDev(rp.config.Core.Dev), 1295 + ); err != nil { 1296 + log.Println("failed to create spindle client", err) 1297 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1298 + log.Println("failed to fetch secrets", err) 1299 + } else { 1300 + secrets = resp.Secrets 965 1301 } 1302 + } 966 1303 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, 1304 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1305 + return strings.Compare(a.Key, b.Key) 1306 + }) 1307 + 1308 + var dids []string 1309 + for _, s := range secrets { 1310 + dids = append(dids, s.CreatedBy) 1311 + } 1312 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1313 + 1314 + // convert to a more manageable form 1315 + var niceSecret []map[string]any 1316 + for id, s := range secrets { 1317 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1318 + niceSecret = append(niceSecret, map[string]any{ 1319 + "Id": id, 1320 + "Key": s.Key, 1321 + "CreatedAt": when, 1322 + "CreatedBy": resolvedIdents[id].Handle.String(), 975 1323 }) 976 1324 } 1325 + 1326 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1327 + LoggedInUser: user, 1328 + RepoInfo: f.RepoInfo(user), 1329 + Tabs: settingsTabs, 1330 + Tab: "pipelines", 1331 + Spindles: spindles, 1332 + CurrentSpindle: f.Spindle, 1333 + Secrets: niceSecret, 1334 + }) 977 1335 } 978 1336 979 1337 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1338 + ref := chi.URLParam(r, "ref") 1339 + 980 1340 user := rp.oauth.GetUser(r) 981 1341 f, err := rp.repoResolver.Resolve(r) 982 1342 if err != nil { ··· 1004 1364 } else { 1005 1365 uri = "https" 1006 1366 } 1007 - forkName := fmt.Sprintf("%s", f.RepoName) 1008 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1367 + forkName := fmt.Sprintf("%s", f.Name) 1368 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1009 1369 1010 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1370 + _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1011 1371 if err != nil { 1012 1372 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1013 1373 return ··· 1055 1415 return 1056 1416 } 1057 1417 1058 - forkName := fmt.Sprintf("%s", f.RepoName) 1418 + forkName := fmt.Sprintf("%s", f.Name) 1059 1419 1060 1420 // this check is *only* to see if the forked repo name already exists 1061 1421 // in the user's account. 1062 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1422 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1063 1423 if err != nil { 1064 1424 if errors.Is(err, sql.ErrNoRows) { 1065 1425 // no existing repo with this name found, we can use the name as is ··· 1090 1450 } else { 1091 1451 uri = "https" 1092 1452 } 1093 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1094 - sourceAt := f.RepoAt.String() 1453 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1454 + sourceAt := f.RepoAt().String() 1095 1455 1096 - rkey := appview.TID() 1456 + rkey := tid.TID() 1097 1457 repo := &db.Repo{ 1098 1458 Did: user.Did, 1099 1459 Name: forkName, ··· 1160 1520 } 1161 1521 log.Println("created repo record: ", atresp.Uri) 1162 1522 1163 - repo.AtUri = atresp.Uri 1164 1523 err = db.AddRepo(tx, repo) 1165 1524 if err != nil { 1166 1525 log.Println(err) ··· 1211 1570 return 1212 1571 } 1213 1572 1214 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1573 + result, err := us.Branches(f.OwnerDid(), f.Name) 1215 1574 if err != nil { 1216 1575 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1217 1576 log.Println("failed to reach knotserver", err) 1218 1577 return 1219 1578 } 1220 1579 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 - }) 1580 + 1581 + sortBranches(branches) 1224 1582 1225 1583 var defaultBranch string 1226 1584 for _, b := range branches { ··· 1242 1600 head = queryHead 1243 1601 } 1244 1602 1245 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1603 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1246 1604 if err != nil { 1247 1605 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1248 1606 log.Println("failed to reach knotserver", err) ··· 1269 1627 return 1270 1628 } 1271 1629 1630 + var diffOpts types.DiffOpts 1631 + if d := r.URL.Query().Get("diff"); d == "split" { 1632 + diffOpts.Split = true 1633 + } 1634 + 1272 1635 // if user is navigating to one of 1273 1636 // /compare/{base}/{head} 1274 1637 // /compare/{base}...{head} ··· 1299 1662 return 1300 1663 } 1301 1664 1302 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1665 + branches, err := us.Branches(f.OwnerDid(), f.Name) 1303 1666 if err != nil { 1304 1667 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1305 1668 log.Println("failed to reach knotserver", err) 1306 1669 return 1307 1670 } 1308 1671 1309 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1672 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1310 1673 if err != nil { 1311 1674 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1312 1675 log.Println("failed to reach knotserver", err) 1313 1676 return 1314 1677 } 1315 1678 1316 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1679 + formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1317 1680 if err != nil { 1318 1681 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1319 1682 log.Println("failed to compare", err) ··· 1331 1694 Base: base, 1332 1695 Head: head, 1333 1696 Diff: &diff, 1697 + DiffOpts: diffOpts, 1334 1698 }) 1335 1699 1336 1700 }
+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{})
+7
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 13 14 r.Get("/commits/{ref}", rp.RepoLog) 14 15 r.Route("/tree/{ref}", func(r chi.Router) { 15 16 r.Get("/", rp.RepoIndex) ··· 37 38 }) 38 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 40 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 + 42 + // intentionally doesn't use /* as this isn't 43 + // a file path 44 + r.Get("/archive/{ref}", rp.DownloadArchive) 40 45 41 46 r.Route("/fork", func(r chi.Router) { 42 47 r.Use(middleware.AuthMiddleware(rp.oauth)) ··· 74 79 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 80 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 81 r.Put("/branches/default", rp.SetDefaultBranch) 82 + r.Put("/secrets", rp.Secrets) 83 + r.Delete("/secrets", rp.Secrets) 77 84 }) 78 85 }) 79 86
+42 -108
appview/reporesolver/resolver.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 - "net/url" 11 10 "path" 11 + "regexp" 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/go-chi/chi/v5" 18 17 "tangled.sh/tangled.sh/core/appview/config" 19 18 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 19 "tangled.sh/tangled.sh/core/appview/oauth" 22 20 "tangled.sh/tangled.sh/core/appview/pages" 23 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 - "tangled.sh/tangled.sh/core/knotclient" 22 + "tangled.sh/tangled.sh/core/idresolver" 25 23 "tangled.sh/tangled.sh/core/rbac" 26 24 ) 27 25 28 26 type ResolvedRepo struct { 29 - Knot string 30 - OwnerId identity.Identity 31 - RepoName string 32 - RepoAt syntax.ATURI 33 - Description string 34 - Spindle string 35 - CreatedAt string 36 - Ref string 37 - CurrentDir string 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 38 31 39 32 rr *RepoResolver 40 33 } ··· 51 44 } 52 45 53 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 56 48 if !ok { 57 - log.Println("malformed middleware") 49 + log.Println("malformed middleware: `repo` not exist in context") 58 50 return nil, fmt.Errorf("malformed middleware") 59 51 } 60 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 55 return nil, fmt.Errorf("malformed middleware") 64 56 } 65 57 66 - repoAt, ok := r.Context().Value("repoAt").(string) 67 - if !ok { 68 - log.Println("malformed middleware") 69 - return nil, fmt.Errorf("malformed middleware") 70 - } 71 - 72 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 - if err != nil { 74 - log.Println("malformed repo at-uri") 75 - return nil, fmt.Errorf("malformed middleware") 76 - } 77 - 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 78 59 ref := chi.URLParam(r, "ref") 79 60 80 - if ref == "" { 81 - us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 82 - if err != nil { 83 - return nil, err 84 - } 85 - 86 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 87 - if err != nil { 88 - return nil, err 89 - } 90 - 91 - ref = defaultBranch.Branch 92 - } 93 - 94 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 - 96 - // pass through values from the middleware 97 - description, ok := r.Context().Value("repoDescription").(string) 98 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 - spindle, ok := r.Context().Value("repoSpindle").(string) 100 - 101 61 return &ResolvedRepo{ 102 - Knot: knot, 103 - OwnerId: id, 104 - RepoName: repoName, 105 - RepoAt: parsedRepoAt, 106 - Description: description, 107 - CreatedAt: addedAt, 108 - Ref: ref, 109 - CurrentDir: currentDir, 110 - Spindle: spindle, 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 111 66 112 67 rr: rr, 113 68 }, nil ··· 126 81 127 82 var p string 128 83 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 130 85 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 132 87 } 133 88 134 89 return p 135 90 } 136 91 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 - return p 140 - } 141 - 142 92 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 143 93 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 144 94 if err != nil { ··· 149 99 for _, item := range repoCollaborators { 150 100 // currently only two roles: owner and member 151 101 var role string 152 - if item[3] == "repo:owner" { 102 + switch item[3] { 103 + case "repo:owner": 153 104 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 105 + case "repo:collaborator": 155 106 role = "collaborator" 156 - } else { 107 + default: 157 108 continue 158 109 } 159 110 ··· 186 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 187 138 // package. we should refactor this or get rid of RepoInfo entirely. 188 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 189 141 isStarred := false 190 142 if user != nil { 191 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 192 144 } 193 145 194 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 195 147 if err != nil { 196 - log.Println("failed to get star count for ", f.RepoAt) 148 + log.Println("failed to get star count for ", repoAt) 197 149 } 198 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 199 151 if err != nil { 200 - log.Println("failed to get issue count for ", f.RepoAt) 152 + log.Println("failed to get issue count for ", repoAt) 201 153 } 202 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 203 155 if err != nil { 204 - log.Println("failed to get issue count for ", f.RepoAt) 156 + log.Println("failed to get issue count for ", repoAt) 205 157 } 206 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 207 159 if errors.Is(err, sql.ErrNoRows) { 208 160 source = "" 209 161 } else if err != nil { 210 - log.Println("failed to get repo source for ", f.RepoAt, err) 162 + log.Println("failed to get repo source for ", repoAt, err) 211 163 } 212 164 213 165 var sourceRepo *db.Repo ··· 227 179 } 228 180 229 181 knot := f.Knot 230 - var disableFork bool 231 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 232 - if err != nil { 233 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 234 - } else { 235 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 236 - if err != nil { 237 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 238 - } 239 - 240 - if len(result.Branches) == 0 { 241 - disableFork = true 242 - } 243 - } 244 182 245 183 repoInfo := repoinfo.RepoInfo{ 246 184 OwnerDid: f.OwnerDid(), 247 185 OwnerHandle: f.OwnerHandle(), 248 - Name: f.RepoName, 249 - RepoAt: f.RepoAt, 186 + Name: f.Name, 187 + RepoAt: repoAt, 250 188 Description: f.Description, 251 - Ref: f.Ref, 252 189 IsStarred: isStarred, 253 190 Knot: knot, 254 191 Spindle: f.Spindle, ··· 258 195 IssueCount: issueCount, 259 196 PullCount: pullCount, 260 197 }, 261 - DisableFork: disableFork, 262 - CurrentDir: f.CurrentDir, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 263 200 } 264 201 265 202 if sourceRepo != nil { ··· 283 220 // after the ref. for example: 284 221 // 285 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 286 - func extractPathAfterRef(fullPath, ref string) string { 223 + func extractPathAfterRef(fullPath string) string { 287 224 fullPath = strings.TrimPrefix(fullPath, "/") 288 225 289 - ref = url.PathEscape(ref) 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 290 230 291 - prefixes := []string{ 292 - fmt.Sprintf("blob/%s/", ref), 293 - fmt.Sprintf("tree/%s/", ref), 294 - fmt.Sprintf("raw/%s/", ref), 295 - } 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 296 233 297 - for _, prefix := range prefixes { 298 - idx := strings.Index(fullPath, prefix) 299 - if idx != -1 { 300 - return fullPath[idx+len(prefix):] 301 - } 234 + if len(matches) > 1 { 235 + return matches[1] 302 236 } 303 237 304 238 return ""
+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 + }
+256
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.Get("/", s.signup) 108 + r.Post("/", s.signup) 109 + r.Get("/complete", s.complete) 110 + r.Post("/complete", s.complete) 111 + 112 + return r 113 + } 114 + 115 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 + switch r.Method { 117 + case http.MethodGet: 118 + s.pages.Signup(w) 119 + case http.MethodPost: 120 + if s.cf == nil { 121 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 + } 123 + emailId := r.FormValue("email") 124 + 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 130 + 131 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 + if err != nil { 133 + s.l.Error("failed to check email existence", "error", err) 134 + s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 + return 136 + } 137 + if exists { 138 + s.pages.Notice(w, noticeId, "Email already exists.") 139 + return 140 + } 141 + 142 + code, err := s.inviteCodeRequest() 143 + if err != nil { 144 + s.l.Error("failed to create invite code", "error", err) 145 + s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 + return 147 + } 148 + 149 + em := email.Email{ 150 + APIKey: s.config.Resend.ApiKey, 151 + From: s.config.Resend.SentFrom, 152 + To: emailId, 153 + Subject: "Verify your Tangled account", 154 + Text: `Copy and paste this code below to verify your account on Tangled. 155 + ` + code, 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 157 + <p><code>` + code + `</code></p>`, 158 + } 159 + 160 + err = email.SendEmail(em) 161 + if err != nil { 162 + s.l.Error("failed to send email", "error", err) 163 + s.pages.Notice(w, noticeId, "Failed to send email.") 164 + return 165 + } 166 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 + Email: emailId, 168 + InviteCode: code, 169 + }) 170 + if err != nil { 171 + s.l.Error("failed to add inflight signup", "error", err) 172 + s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 173 + return 174 + } 175 + 176 + s.pages.HxRedirect(w, "/signup/complete") 177 + } 178 + } 179 + 180 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 181 + switch r.Method { 182 + case http.MethodGet: 183 + s.pages.CompleteSignup(w) 184 + case http.MethodPost: 185 + username := r.FormValue("username") 186 + password := r.FormValue("password") 187 + code := r.FormValue("code") 188 + 189 + if !userutil.IsValidSubdomain(username) { 190 + 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.") 191 + return 192 + } 193 + 194 + if !s.isNicknameAllowed(username) { 195 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 196 + return 197 + } 198 + 199 + email, err := db.GetEmailForCode(s.db, code) 200 + if err != nil { 201 + s.l.Error("failed to get email for code", "error", err) 202 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 203 + return 204 + } 205 + 206 + did, err := s.createAccountRequest(username, password, email, code) 207 + if err != nil { 208 + s.l.Error("failed to create account", "error", err) 209 + s.pages.Notice(w, "signup-error", err.Error()) 210 + return 211 + } 212 + 213 + if s.cf == nil { 214 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 215 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 216 + return 217 + } 218 + 219 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 220 + Type: "TXT", 221 + Name: "_atproto." + username, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 223 + TTL: 6400, 224 + Proxied: false, 225 + }) 226 + if err != nil { 227 + s.l.Error("failed to create DNS record", "error", err) 228 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + return 230 + } 231 + 232 + err = db.AddEmail(s.db, db.Email{ 233 + Did: did, 234 + Address: email, 235 + Verified: true, 236 + Primary: true, 237 + }) 238 + if err != nil { 239 + s.l.Error("failed to add email", "error", err) 240 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 241 + return 242 + } 243 + 244 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 245 + <a class="underline text-black dark:text-white" href="/login">login</a> 246 + with <code>%s.tngl.sh</code>.`, username)) 247 + 248 + go func() { 249 + err := db.DeleteInflightSignup(s.db, email) 250 + if err != nil { 251 + s.l.Error("failed to delete inflight signup", "error", err) 252 + } 253 + }() 254 + return 255 + } 256 + }
+29 -30
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 { ··· 112 113 return 113 114 } 114 115 115 - identsToResolve := make([]string, len(members)) 116 - for i, member := range members { 117 - identsToResolve[i] = member 118 - } 119 - resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 120 - didHandleMap := make(map[string]string) 121 - for _, identity := range resolvedIds { 122 - if !identity.Handle.IsInvalidHandle() { 123 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 124 - } else { 125 - didHandleMap[identity.DID.String()] = identity.DID.String() 126 - } 127 - } 128 - 129 116 // organize repos by did 130 117 repoMap := make(map[string][]db.Repo) 131 118 for _, r := range repos { ··· 137 124 Spindle: spindle, 138 125 Members: members, 139 126 Repos: repoMap, 140 - DidHandleMap: didHandleMap, 141 127 }) 142 128 } 143 129 ··· 257 243 258 244 // ok 259 245 s.Pages.HxRefresh(w) 260 - return 261 246 } 262 247 263 248 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 305 290 s.Enforcer.E.LoadPolicy() 306 291 }() 307 292 293 + // remove spindle members first 294 + err = db.RemoveSpindleMember( 295 + tx, 296 + db.FilterEq("did", user.Did), 297 + db.FilterEq("instance", instance), 298 + ) 299 + if err != nil { 300 + l.Error("failed to remove spindle members", "err", err) 301 + fail() 302 + return 303 + } 304 + 308 305 err = db.DeleteSpindle( 309 306 tx, 310 307 db.FilterEq("owner", user.Did), ··· 316 313 return 317 314 } 318 315 319 - err = s.Enforcer.RemoveSpindle(instance) 320 - if err != nil { 321 - l.Error("failed to update ACL", "err", err) 322 - fail() 323 - return 316 + // delete from enforcer 317 + if spindles[0].Verified != nil { 318 + err = s.Enforcer.RemoveSpindle(instance) 319 + if err != nil { 320 + l.Error("failed to update ACL", "err", err) 321 + fail() 322 + return 323 + } 324 324 } 325 325 326 326 client, err := s.OAuth.AuthorizedClient(r) ··· 520 520 s.Enforcer.E.LoadPolicy() 521 521 }() 522 522 523 - rkey := appview.TID() 523 + rkey := tid.TID() 524 524 525 525 // add member to db 526 526 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 579 579 l := s.Logger.With("handler", "removeMember") 580 580 581 581 noticeId := "operation-error" 582 - defaultErr := "Failed to add member. Try again later." 582 + defaultErr := "Failed to remove member. Try again later." 583 583 fail := func() { 584 584 s.Pages.Notice(w, noticeId, defaultErr) 585 585 } ··· 606 606 607 607 if string(spindles[0].Owner) != user.Did { 608 608 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 609 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 609 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 610 610 return 611 611 } 612 612 613 613 member := r.FormValue("member") 614 614 if member == "" { 615 615 l.Error("empty member") 616 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 616 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 617 617 return 618 618 } 619 619 l = l.With("member", member) ··· 621 621 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 622 622 if err != nil { 623 623 l.Error("failed to resolve member identity to handle", "err", err) 624 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 624 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 625 625 return 626 626 } 627 627 if memberId.Handle.IsInvalidHandle() { 628 628 l.Error("failed to resolve member identity to handle") 629 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 629 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 630 630 return 631 631 } 632 632 ··· 707 707 708 708 // ok 709 709 s.Pages.HxRefresh(w) 710 - return 711 710 }
+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 }
+9 -12
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "maps" 6 7 "net/http" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/identity" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 10 12 ) 11 13 12 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 15 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 + repo := r.Context().Value("repo").(*db.Repo) 16 17 17 18 scheme := "https" 18 19 if s.config.Core.Dev { 19 20 scheme = "http" 20 21 } 21 22 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 23 24 s.proxyRequest(w, r, targetURL) 24 25 25 26 } ··· 30 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 32 return 32 33 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 34 + repo := r.Context().Value("repo").(*db.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { 38 38 scheme = "http" 39 39 } 40 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 42 s.proxyRequest(w, r, targetURL) 43 43 } 44 44 ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 51 + repo := r.Context().Value("repo").(*db.Repo) 53 52 54 53 scheme := "https" 55 54 if s.config.Core.Dev { 56 55 scheme = "http" 57 56 } 58 57 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 60 59 s.proxyRequest(w, r, targetURL) 61 60 } 62 61 ··· 85 84 defer resp.Body.Close() 86 85 87 86 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 87 + maps.Copy(w.Header(), resp.Header) 91 88 92 89 // Set response status code 93 90 w.WriteHeader(resp.StatusCode)
+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
+155 -76
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 4 + "context" 7 5 "fmt" 8 6 "log" 9 7 "net/http" ··· 16 14 "github.com/bluesky-social/indigo/atproto/syntax" 17 15 lexutil "github.com/bluesky-social/indigo/lex/util" 18 16 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 17 + "github.com/gorilla/feeds" 20 18 "tangled.sh/tangled.sh/core/api/tangled" 21 19 "tangled.sh/tangled.sh/core/appview/db" 22 20 "tangled.sh/tangled.sh/core/appview/pages" ··· 50 48 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 51 49 } 52 50 53 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 51 + repos, err := db.GetRepos( 52 + s.db, 53 + 0, 54 + db.FilterEq("did", ident.DID.String()), 55 + ) 54 56 if err != nil { 55 57 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 56 58 } ··· 87 89 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 88 90 } 89 91 90 - var didsToResolve []string 91 - for _, r := range collaboratingRepos { 92 - didsToResolve = append(didsToResolve, r.Did) 93 - } 94 - for _, byMonth := range timeline.ByMonth { 95 - for _, pe := range byMonth.PullEvents.Items { 96 - didsToResolve = append(didsToResolve, pe.Repo.Did) 97 - } 98 - for _, ie := range byMonth.IssueEvents.Items { 99 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 100 - } 101 - for _, re := range byMonth.RepoEvents { 102 - didsToResolve = append(didsToResolve, re.Repo.Did) 103 - if re.Source != nil { 104 - didsToResolve = append(didsToResolve, re.Source.Did) 105 - } 106 - } 107 - } 108 - 109 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 110 - didHandleMap := make(map[string]string) 111 - for _, identity := range resolvedIds { 112 - if !identity.Handle.IsInvalidHandle() { 113 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 114 - } else { 115 - didHandleMap[identity.DID.String()] = identity.DID.String() 116 - } 117 - } 118 - 119 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 92 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 120 93 if err != nil { 121 94 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 122 95 } ··· 139 112 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 140 113 } 141 114 142 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 143 115 s.pages.ProfilePage(w, pages.ProfilePageParams{ 144 116 LoggedInUser: loggedInUser, 145 117 Repos: pinnedRepos, 146 118 CollaboratingRepos: pinnedCollaboratingRepos, 147 - DidHandleMap: didHandleMap, 148 119 Card: pages.ProfileCard{ 149 120 UserDid: ident.DID.String(), 150 121 UserHandle: ident.Handle.String(), 151 - AvatarUri: profileAvatarUri, 152 122 Profile: profile, 153 123 FollowStatus: followStatus, 154 124 Followers: followers, ··· 171 141 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 172 142 } 173 143 174 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 144 + repos, err := db.GetRepos( 145 + s.db, 146 + 0, 147 + db.FilterEq("did", ident.DID.String()), 148 + ) 175 149 if err != nil { 176 150 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 177 151 } ··· 182 156 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 183 157 } 184 158 185 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 159 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 186 160 if err != nil { 187 161 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 188 162 } 189 163 190 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 191 - 192 164 s.pages.ReposPage(w, pages.ReposPageParams{ 193 165 LoggedInUser: loggedInUser, 194 166 Repos: repos, 195 167 Card: pages.ProfileCard{ 196 168 UserDid: ident.DID.String(), 197 169 UserHandle: ident.Handle.String(), 198 - AvatarUri: profileAvatarUri, 199 170 Profile: profile, 200 171 FollowStatus: followStatus, 201 172 Followers: followers, ··· 204 175 }) 205 176 } 206 177 207 - func (s *State) GetAvatarUri(handle string) string { 208 - secret := s.config.Avatar.SharedSecret 209 - h := hmac.New(sha256.New, []byte(secret)) 210 - h.Write([]byte(handle)) 211 - signature := hex.EncodeToString(h.Sum(nil)) 212 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 178 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 179 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 180 + if !ok { 181 + s.pages.Error404(w) 182 + return 183 + } 184 + 185 + feed, err := s.getProfileFeed(r.Context(), &ident) 186 + if err != nil { 187 + s.pages.Error500(w) 188 + return 189 + } 190 + 191 + if feed == nil { 192 + return 193 + } 194 + 195 + atom, err := feed.ToAtom() 196 + if err != nil { 197 + s.pages.Error500(w) 198 + return 199 + } 200 + 201 + w.Header().Set("content-type", "application/atom+xml") 202 + w.Write([]byte(atom)) 203 + } 204 + 205 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 206 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 207 + if err != nil { 208 + return nil, err 209 + } 210 + 211 + author := &feeds.Author{ 212 + Name: fmt.Sprintf("@%s", id.Handle), 213 + } 214 + 215 + feed := feeds.Feed{ 216 + Title: fmt.Sprintf("%s's timeline", author.Name), 217 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 218 + Items: make([]*feeds.Item, 0), 219 + Updated: time.UnixMilli(0), 220 + Author: author, 221 + } 222 + 223 + for _, byMonth := range timeline.ByMonth { 224 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 225 + return nil, err 226 + } 227 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 228 + return nil, err 229 + } 230 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 231 + return nil, err 232 + } 233 + } 234 + 235 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 236 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 237 + }) 238 + 239 + if len(feed.Items) > 0 { 240 + feed.Updated = feed.Items[0].Created 241 + } 242 + 243 + return &feed, nil 244 + } 245 + 246 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 247 + for _, pull := range pulls { 248 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + // Add pull request creation item 254 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 255 + } 256 + return nil 257 + } 258 + 259 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 260 + for _, issue := range issues { 261 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 262 + if err != nil { 263 + return err 264 + } 265 + 266 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 267 + } 268 + return nil 269 + } 270 + 271 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 272 + for _, repo := range repos { 273 + item, err := s.createRepoItem(ctx, repo, author) 274 + if err != nil { 275 + return err 276 + } 277 + feed.Items = append(feed.Items, item) 278 + } 279 + return nil 280 + } 281 + 282 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 283 + return &feeds.Item{ 284 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 285 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 286 + Created: pull.Created, 287 + Author: author, 288 + } 289 + } 290 + 291 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 292 + return &feeds.Item{ 293 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 294 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 295 + Created: issue.Created, 296 + Author: author, 297 + } 298 + } 299 + 300 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 301 + var title string 302 + if repo.Source != nil { 303 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 304 + if err != nil { 305 + return nil, err 306 + } 307 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 308 + } else { 309 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 310 + } 311 + 312 + return &feeds.Item{ 313 + Title: title, 314 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 315 + Created: repo.Repo.Created, 316 + Author: author, 317 + }, nil 213 318 } 214 319 215 320 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 257 362 } 258 363 259 364 s.updateProfile(profile, w, r) 260 - return 261 365 } 262 366 263 367 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 297 401 profile.PinnedRepos = pinnedRepos 298 402 299 403 s.updateProfile(profile, w, r) 300 - return 301 404 } 302 405 303 406 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 362 465 return 363 466 } 364 467 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 - } 468 + s.notifier.UpdateProfile(r.Context(), profile) 374 469 375 470 s.pages.HxRedirect(w, "/"+user.Did) 376 - return 377 471 } 378 472 379 473 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { ··· 425 519 }) 426 520 } 427 521 428 - var didsToResolve []string 429 - for _, r := range allRepos { 430 - didsToResolve = append(didsToResolve, r.Did) 431 - } 432 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 433 - didHandleMap := make(map[string]string) 434 - for _, identity := range resolvedIds { 435 - if !identity.Handle.IsInvalidHandle() { 436 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 437 - } else { 438 - didHandleMap[identity.DID.String()] = identity.DID.String() 439 - } 440 - } 441 - 442 522 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 443 523 LoggedInUser: user, 444 524 Profile: profile, 445 525 AllRepos: allRepos, 446 - DidHandleMap: didHandleMap, 447 526 }) 448 527 }
+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 + }
+74 -28
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" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 18 21 "tangled.sh/tangled.sh/core/log" 19 22 ) 20 23 ··· 29 32 s.pages, 30 33 ) 31 34 35 + router.Get("/favicon.svg", s.Favicon) 36 + router.Get("/favicon.ico", s.Favicon) 37 + 38 + userRouter := s.UserRouter(&middleware) 39 + standardRouter := s.StandardRouter(&middleware) 40 + 32 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 33 42 pat := chi.URLParam(r, "*") 34 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 35 - s.UserRouter(&middleware).ServeHTTP(w, r) 44 + userRouter.ServeHTTP(w, r) 36 45 } else { 37 46 // Check if the first path element is a valid handle without '@' or a flattened DID 38 47 pathParts := strings.SplitN(pat, "/", 2) ··· 55 64 return 56 65 } 57 66 } 58 - s.StandardRouter(&middleware).ServeHTTP(w, r) 67 + standardRouter.ServeHTTP(w, r) 59 68 } 60 69 }) 61 70 ··· 65 74 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 66 75 r := chi.NewRouter() 67 76 68 - // strip @ from user 69 - r.Use(middleware.StripLeadingAt) 70 - 71 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 78 r.Get("/", s.Profile) 79 + r.Get("/feed.atom", s.AtomFeedPage) 80 + 81 + // redirect /@handle/repo.git -> /@handle/repo 82 + r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 83 + nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 84 + http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 85 + }) 73 86 74 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 75 88 r.Use(mw.GoImport()) 76 - 77 89 r.Mount("/", s.RepoRouter(mw)) 78 90 r.Mount("/issues", s.IssuesRouter(mw)) 79 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 101 113 102 114 r.Get("/", s.Timeline) 103 115 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 116 r.Route("/repo", func(r chi.Router) { 122 117 r.Route("/new", func(r chi.Router) { 123 118 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 137 132 r.Delete("/", s.Star) 138 133 }) 139 134 135 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) { 136 + r.Post("/", s.React) 137 + r.Delete("/", s.React) 138 + }) 139 + 140 140 r.Route("/profile", func(r chi.Router) { 141 141 r.Use(middleware.AuthMiddleware(s.oauth)) 142 142 r.Get("/edit-bio", s.EditBioFragment) ··· 146 146 }) 147 147 148 148 r.Mount("/settings", s.SettingsRouter()) 149 + r.Mount("/strings", s.StringsRouter(mw)) 150 + r.Mount("/knots", s.KnotsRouter(mw)) 149 151 r.Mount("/spindles", s.SpindlesRouter()) 152 + r.Mount("/signup", s.SignupRouter()) 150 153 r.Mount("/", s.OAuthRouter()) 151 154 152 155 r.Get("/keys/{user}", s.Keys) 156 + r.Get("/terms", s.TermsOfService) 157 + r.Get("/privacy", s.PrivacyPolicy) 153 158 154 159 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 155 160 s.pages.Error404(w) ··· 190 195 return spindles.Router() 191 196 } 192 197 198 + func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 199 + logger := log.New("knots") 200 + 201 + knots := &knots.Knots{ 202 + Db: s.db, 203 + OAuth: s.oauth, 204 + Pages: s.pages, 205 + Config: s.config, 206 + Enforcer: s.enforcer, 207 + IdResolver: s.idResolver, 208 + Knotstream: s.knotstream, 209 + Logger: logger, 210 + } 211 + 212 + return knots.Router(mw) 213 + } 214 + 215 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 216 + logger := log.New("strings") 217 + 218 + strs := &avstrings.Strings{ 219 + Db: s.db, 220 + OAuth: s.oauth, 221 + Pages: s.pages, 222 + Config: s.config, 223 + Enforcer: s.enforcer, 224 + IdResolver: s.idResolver, 225 + Knotstream: s.knotstream, 226 + Logger: logger, 227 + } 228 + 229 + return strs.Router(mw) 230 + } 231 + 193 232 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) 233 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 195 234 return issues.Router(mw) 196 - 197 235 } 198 236 199 237 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) 238 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 201 239 return pulls.Router(mw) 202 240 } 203 241 204 242 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) 243 + logger := log.New("repo") 244 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 206 245 return repo.Router(mw) 207 246 } 208 247 209 248 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) 249 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 211 250 return pipes.Router(mw) 212 251 } 252 + 253 + func (s *State) SignupRouter() http.Handler { 254 + logger := log.New("signup") 255 + 256 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 257 + return sig.Router() 258 + }
+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 }
+57 -383
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 - pgs := pages.NewPages(config) 68 - 69 - res, err := idresolver.RedisResolver(config.Redis) 64 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 70 65 if err != nil { 71 66 log.Printf("failed to create redis resolver: %v", err) 72 67 res = idresolver.DefaultResolver() 73 68 } 69 + 70 + pgs := pages.NewPages(config, res) 74 71 75 72 cache := cache.New(config.Redis.Addr) 76 73 sess := session.New(cache) ··· 96 93 tangled.ActorProfileNSID, 97 94 tangled.SpindleMemberNSID, 98 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 97 + tangled.RepoIssueNSID, 98 + tangled.RepoIssueCommentNSID, 99 99 }, 100 100 nil, 101 101 slog.Default(), ··· 133 133 return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err) 134 134 } 135 135 spindlestream.Start(ctx) 136 + 137 + var notifiers []notify.Notifier 138 + if !config.Core.Dev { 139 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 140 + } 141 + notifier := notify.NewMergedNotifier(notifiers...) 136 142 137 143 state := &State{ 138 144 d, 145 + notifier, 139 146 oauth, 140 147 enforcer, 141 - clock, 142 148 pgs, 143 149 sess, 144 150 res, ··· 153 159 return state, nil 154 160 } 155 161 156 - func TID(c *syntax.TIDClock) string { 157 - return c.Next().String() 162 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 163 + w.Header().Set("Content-Type", "image/svg+xml") 164 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 165 + w.Header().Set("ETag", `"favicon-svg-v1"`) 166 + 167 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 168 + w.WriteHeader(http.StatusNotModified) 169 + return 170 + } 171 + 172 + s.pages.Favicon(w) 173 + } 174 + 175 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 176 + user := s.oauth.GetUser(r) 177 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 178 + LoggedInUser: user, 179 + }) 180 + } 181 + 182 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 183 + user := s.oauth.GetUser(r) 184 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 185 + LoggedInUser: user, 186 + }) 158 187 } 159 188 160 189 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 166 195 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 167 196 } 168 197 169 - var didsToResolve []string 170 - for _, ev := range timeline { 171 - if ev.Repo != nil { 172 - didsToResolve = append(didsToResolve, ev.Repo.Did) 173 - if ev.Source != nil { 174 - didsToResolve = append(didsToResolve, ev.Source.Did) 175 - } 176 - } 177 - if ev.Follow != nil { 178 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 179 - } 180 - if ev.Star != nil { 181 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 182 - } 183 - } 184 - 185 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 186 - didHandleMap := make(map[string]string) 187 - for _, identity := range resolvedIds { 188 - if !identity.Handle.IsInvalidHandle() { 189 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 190 - } else { 191 - didHandleMap[identity.DID.String()] = identity.DID.String() 192 - } 198 + repos, err := db.GetTopStarredReposLastWeek(s.db) 199 + if err != nil { 200 + log.Println(err) 201 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 202 + return 193 203 } 194 204 195 205 s.pages.Timeline(w, pages.TimelineParams{ 196 206 LoggedInUser: user, 197 207 Timeline: timeline, 198 - DidHandleMap: didHandleMap, 208 + Repos: repos, 199 209 }) 200 - 201 - return 202 - } 203 - 204 - // requires auth 205 - func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 206 - switch r.Method { 207 - case http.MethodGet: 208 - // list open registrations under this did 209 - 210 - return 211 - case http.MethodPost: 212 - session, err := s.oauth.Stores().Get(r, oauth.SessionName) 213 - if err != nil || session.IsNew { 214 - log.Println("unauthorized attempt to generate registration key") 215 - http.Error(w, "Forbidden", http.StatusUnauthorized) 216 - return 217 - } 218 - 219 - did := session.Values[oauth.SessionDid].(string) 220 - 221 - // check if domain is valid url, and strip extra bits down to just host 222 - domain := r.FormValue("domain") 223 - if domain == "" { 224 - http.Error(w, "Invalid form", http.StatusBadRequest) 225 - return 226 - } 227 - 228 - key, err := db.GenerateRegistrationKey(s.db, domain, did) 229 - 230 - if err != nil { 231 - log.Println(err) 232 - http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 233 - return 234 - } 235 - 236 - w.Write([]byte(key)) 237 - } 238 210 } 239 211 240 212 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 269 241 } 270 242 } 271 243 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 244 func validateRepoName(name string) error { 567 245 // check for path traversal attempts 568 246 if name == "." || name == ".." || ··· 595 273 return nil 596 274 } 597 275 276 + func stripGitExt(name string) string { 277 + return strings.TrimSuffix(name, ".git") 278 + } 279 + 598 280 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 599 281 switch r.Method { 600 282 case http.MethodGet: ··· 630 312 return 631 313 } 632 314 315 + repoName = stripGitExt(repoName) 316 + 633 317 defaultBranch := r.FormValue("branch") 634 318 if defaultBranch == "" { 635 319 defaultBranch = "main" ··· 661 345 return 662 346 } 663 347 664 - rkey := appview.TID() 348 + rkey := tid.TID() 665 349 repo := &db.Repo{ 666 350 Did: user.Did, 667 351 Name: repoName, ··· 726 410 // continue 727 411 } 728 412 729 - repo.AtUri = atresp.Uri 730 413 err = db.AddRepo(tx, repo) 731 414 if err != nil { 732 415 log.Println(err) ··· 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 }
+465
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "slices" 9 + "strconv" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/middleware" 16 + "tangled.sh/tangled.sh/core/appview/oauth" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 + "tangled.sh/tangled.sh/core/eventconsumer" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + "tangled.sh/tangled.sh/core/rbac" 22 + "tangled.sh/tangled.sh/core/tid" 23 + 24 + "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/identity" 26 + "github.com/bluesky-social/indigo/atproto/syntax" 27 + lexutil "github.com/bluesky-social/indigo/lex/util" 28 + "github.com/go-chi/chi/v5" 29 + ) 30 + 31 + type Strings 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 (s *Strings) Router(mw *middleware.Middleware) http.Handler { 43 + r := chi.NewRouter() 44 + 45 + r. 46 + Get("/", s.timeline) 47 + 48 + r. 49 + With(mw.ResolveIdent()). 50 + Route("/{user}", func(r chi.Router) { 51 + r.Get("/", s.dashboard) 52 + 53 + r.Route("/{rkey}", func(r chi.Router) { 54 + r.Get("/", s.contents) 55 + r.Delete("/", s.delete) 56 + r.Get("/raw", s.contents) 57 + r.Get("/edit", s.edit) 58 + r.Post("/edit", s.edit) 59 + r. 60 + With(middleware.AuthMiddleware(s.OAuth)). 61 + Post("/comment", s.comment) 62 + }) 63 + }) 64 + 65 + r. 66 + With(middleware.AuthMiddleware(s.OAuth)). 67 + Route("/new", func(r chi.Router) { 68 + r.Get("/", s.create) 69 + r.Post("/", s.create) 70 + }) 71 + 72 + return r 73 + } 74 + 75 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 76 + l := s.Logger.With("handler", "timeline") 77 + 78 + strings, err := db.GetStrings(s.Db, 50) 79 + if err != nil { 80 + l.Error("failed to fetch string", "err", err) 81 + w.WriteHeader(http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 86 + LoggedInUser: s.OAuth.GetUser(r), 87 + Strings: strings, 88 + }) 89 + } 90 + 91 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 92 + l := s.Logger.With("handler", "contents") 93 + 94 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 95 + if !ok { 96 + l.Error("malformed middleware") 97 + w.WriteHeader(http.StatusInternalServerError) 98 + return 99 + } 100 + l = l.With("did", id.DID, "handle", id.Handle) 101 + 102 + rkey := chi.URLParam(r, "rkey") 103 + if rkey == "" { 104 + l.Error("malformed url, empty rkey") 105 + w.WriteHeader(http.StatusBadRequest) 106 + return 107 + } 108 + l = l.With("rkey", rkey) 109 + 110 + strings, err := db.GetStrings( 111 + s.Db, 112 + 0, 113 + db.FilterEq("did", id.DID), 114 + db.FilterEq("rkey", rkey), 115 + ) 116 + if err != nil { 117 + l.Error("failed to fetch string", "err", err) 118 + w.WriteHeader(http.StatusInternalServerError) 119 + return 120 + } 121 + if len(strings) < 1 { 122 + l.Error("string not found") 123 + s.Pages.Error404(w) 124 + return 125 + } 126 + if len(strings) != 1 { 127 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 128 + w.WriteHeader(http.StatusInternalServerError) 129 + return 130 + } 131 + string := strings[0] 132 + 133 + if path.Base(r.URL.Path) == "raw" { 134 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 135 + if string.Filename != "" { 136 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 137 + } 138 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 139 + 140 + _, err = w.Write([]byte(string.Contents)) 141 + if err != nil { 142 + l.Error("failed to write raw response", "err", err) 143 + } 144 + return 145 + } 146 + 147 + var showRendered, renderToggle bool 148 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 149 + renderToggle = true 150 + showRendered = r.URL.Query().Get("code") != "true" 151 + } 152 + 153 + s.Pages.SingleString(w, pages.SingleStringParams{ 154 + LoggedInUser: s.OAuth.GetUser(r), 155 + RenderToggle: renderToggle, 156 + ShowRendered: showRendered, 157 + String: string, 158 + Stats: string.Stats(), 159 + Owner: id, 160 + }) 161 + } 162 + 163 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 + l := s.Logger.With("handler", "dashboard") 165 + 166 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 + if !ok { 168 + l.Error("malformed middleware") 169 + w.WriteHeader(http.StatusInternalServerError) 170 + return 171 + } 172 + l = l.With("did", id.DID, "handle", id.Handle) 173 + 174 + all, err := db.GetStrings( 175 + s.Db, 176 + 0, 177 + db.FilterEq("did", id.DID), 178 + ) 179 + if err != nil { 180 + l.Error("failed to fetch strings", "err", err) 181 + w.WriteHeader(http.StatusInternalServerError) 182 + return 183 + } 184 + 185 + slices.SortFunc(all, func(a, b db.String) int { 186 + if a.Created.After(b.Created) { 187 + return -1 188 + } else { 189 + return 1 190 + } 191 + }) 192 + 193 + profile, err := db.GetProfile(s.Db, id.DID.String()) 194 + if err != nil { 195 + l.Error("failed to fetch user profile", "err", err) 196 + w.WriteHeader(http.StatusInternalServerError) 197 + return 198 + } 199 + loggedInUser := s.OAuth.GetUser(r) 200 + followStatus := db.IsNotFollowing 201 + if loggedInUser != nil { 202 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 + } 204 + 205 + followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 + if err != nil { 207 + l.Error("failed to get follow stats", "err", err) 208 + } 209 + 210 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 + LoggedInUser: s.OAuth.GetUser(r), 212 + Card: pages.ProfileCard{ 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + Followers: followers, 218 + Following: following, 219 + }, 220 + Strings: all, 221 + }) 222 + } 223 + 224 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 225 + l := s.Logger.With("handler", "edit") 226 + 227 + user := s.OAuth.GetUser(r) 228 + 229 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 230 + if !ok { 231 + l.Error("malformed middleware") 232 + w.WriteHeader(http.StatusInternalServerError) 233 + return 234 + } 235 + l = l.With("did", id.DID, "handle", id.Handle) 236 + 237 + rkey := chi.URLParam(r, "rkey") 238 + if rkey == "" { 239 + l.Error("malformed url, empty rkey") 240 + w.WriteHeader(http.StatusBadRequest) 241 + return 242 + } 243 + l = l.With("rkey", rkey) 244 + 245 + // get the string currently being edited 246 + all, err := db.GetStrings( 247 + s.Db, 248 + 0, 249 + db.FilterEq("did", id.DID), 250 + db.FilterEq("rkey", rkey), 251 + ) 252 + if err != nil { 253 + l.Error("failed to fetch string", "err", err) 254 + w.WriteHeader(http.StatusInternalServerError) 255 + return 256 + } 257 + if len(all) != 1 { 258 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 259 + w.WriteHeader(http.StatusInternalServerError) 260 + return 261 + } 262 + first := all[0] 263 + 264 + // verify that the logged in user owns this string 265 + if user.Did != id.DID.String() { 266 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 267 + w.WriteHeader(http.StatusUnauthorized) 268 + return 269 + } 270 + 271 + switch r.Method { 272 + case http.MethodGet: 273 + // return the form with prefilled fields 274 + s.Pages.PutString(w, pages.PutStringParams{ 275 + LoggedInUser: s.OAuth.GetUser(r), 276 + Action: "edit", 277 + String: first, 278 + }) 279 + case http.MethodPost: 280 + fail := func(msg string, err error) { 281 + l.Error(msg, "err", err) 282 + s.Pages.Notice(w, "error", msg) 283 + } 284 + 285 + filename := r.FormValue("filename") 286 + if filename == "" { 287 + fail("Empty filename.", nil) 288 + return 289 + } 290 + 291 + content := r.FormValue("content") 292 + if content == "" { 293 + fail("Empty contents.", nil) 294 + return 295 + } 296 + 297 + description := r.FormValue("description") 298 + 299 + // construct new string from form values 300 + entry := db.String{ 301 + Did: first.Did, 302 + Rkey: first.Rkey, 303 + Filename: filename, 304 + Description: description, 305 + Contents: content, 306 + Created: first.Created, 307 + } 308 + 309 + record := entry.AsRecord() 310 + 311 + client, err := s.OAuth.AuthorizedClient(r) 312 + if err != nil { 313 + fail("Failed to create record.", err) 314 + return 315 + } 316 + 317 + // first replace the existing record in the PDS 318 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 319 + if err != nil { 320 + fail("Failed to updated existing record.", err) 321 + return 322 + } 323 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 324 + Collection: tangled.StringNSID, 325 + Repo: entry.Did.String(), 326 + Rkey: entry.Rkey, 327 + SwapRecord: ex.Cid, 328 + Record: &lexutil.LexiconTypeDecoder{ 329 + Val: &record, 330 + }, 331 + }) 332 + if err != nil { 333 + fail("Failed to updated existing record.", err) 334 + return 335 + } 336 + l := l.With("aturi", resp.Uri) 337 + l.Info("edited string") 338 + 339 + // if that went okay, updated the db 340 + if err = db.AddString(s.Db, entry); err != nil { 341 + fail("Failed to update string.", err) 342 + return 343 + } 344 + 345 + // if that went okay, redir to the string 346 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 347 + } 348 + 349 + } 350 + 351 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 352 + l := s.Logger.With("handler", "create") 353 + user := s.OAuth.GetUser(r) 354 + 355 + switch r.Method { 356 + case http.MethodGet: 357 + s.Pages.PutString(w, pages.PutStringParams{ 358 + LoggedInUser: s.OAuth.GetUser(r), 359 + Action: "new", 360 + }) 361 + case http.MethodPost: 362 + fail := func(msg string, err error) { 363 + l.Error(msg, "err", err) 364 + s.Pages.Notice(w, "error", msg) 365 + } 366 + 367 + filename := r.FormValue("filename") 368 + if filename == "" { 369 + fail("Empty filename.", nil) 370 + return 371 + } 372 + 373 + content := r.FormValue("content") 374 + if content == "" { 375 + fail("Empty contents.", nil) 376 + return 377 + } 378 + 379 + description := r.FormValue("description") 380 + 381 + string := db.String{ 382 + Did: syntax.DID(user.Did), 383 + Rkey: tid.TID(), 384 + Filename: filename, 385 + Description: description, 386 + Contents: content, 387 + Created: time.Now(), 388 + } 389 + 390 + record := string.AsRecord() 391 + 392 + client, err := s.OAuth.AuthorizedClient(r) 393 + if err != nil { 394 + fail("Failed to create record.", err) 395 + return 396 + } 397 + 398 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 399 + Collection: tangled.StringNSID, 400 + Repo: user.Did, 401 + Rkey: string.Rkey, 402 + Record: &lexutil.LexiconTypeDecoder{ 403 + Val: &record, 404 + }, 405 + }) 406 + if err != nil { 407 + fail("Failed to create record.", err) 408 + return 409 + } 410 + l := l.With("aturi", resp.Uri) 411 + l.Info("created record") 412 + 413 + // insert into DB 414 + if err = db.AddString(s.Db, string); err != nil { 415 + fail("Failed to create string.", err) 416 + return 417 + } 418 + 419 + // successful 420 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 421 + } 422 + } 423 + 424 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 425 + l := s.Logger.With("handler", "create") 426 + user := s.OAuth.GetUser(r) 427 + fail := func(msg string, err error) { 428 + l.Error(msg, "err", err) 429 + s.Pages.Notice(w, "error", msg) 430 + } 431 + 432 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 433 + if !ok { 434 + l.Error("malformed middleware") 435 + w.WriteHeader(http.StatusInternalServerError) 436 + return 437 + } 438 + l = l.With("did", id.DID, "handle", id.Handle) 439 + 440 + rkey := chi.URLParam(r, "rkey") 441 + if rkey == "" { 442 + l.Error("malformed url, empty rkey") 443 + w.WriteHeader(http.StatusBadRequest) 444 + return 445 + } 446 + 447 + if user.Did != id.DID.String() { 448 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 449 + return 450 + } 451 + 452 + if err := db.DeleteString( 453 + s.Db, 454 + db.FilterEq("did", user.Did), 455 + db.FilterEq("rkey", rkey), 456 + ); err != nil { 457 + fail("Failed to delete string.", err) 458 + return 459 + } 460 + 461 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 462 + } 463 + 464 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 465 + }
-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) {
+5 -2
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{}, 26 29 tangled.Pipeline_CloneOpts{}, 27 - tangled.Pipeline_Dependency{}, 28 30 tangled.Pipeline_ManualTriggerData{}, 29 31 tangled.Pipeline_Pair{}, 30 32 tangled.Pipeline_PullRequestTriggerData{}, 31 33 tangled.Pipeline_PushTriggerData{}, 32 34 tangled.PipelineStatus{}, 33 - tangled.Pipeline_Step{}, 34 35 tangled.Pipeline_TriggerMetadata{}, 35 36 tangled.Pipeline_TriggerRepo{}, 36 37 tangled.Pipeline_Workflow{}, 37 38 tangled.PublicKey{}, 38 39 tangled.Repo{}, 39 40 tangled.RepoArtifact{}, 41 + tangled.RepoCollaborator{}, 40 42 tangled.RepoIssue{}, 41 43 tangled.RepoIssueComment{}, 42 44 tangled.RepoIssueState{}, ··· 46 48 tangled.RepoPullStatus{}, 47 49 tangled.Spindle{}, 48 50 tangled.SpindleMember{}, 51 + tangled.String{}, 49 52 ); err != nil { 50 53 panic(err) 51 54 }
+4
cmd/genjwks/main.go
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+1 -1
cmd/punchcardPopulate/main.go
··· 11 11 ) 12 12 13 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 15 if err != nil { 16 16 log.Fatal("Failed to open database:", err) 17 17 }
+14 -15
docs/contributing.md
··· 55 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 56 before submitting if necessary. 57 57 58 + ## code formatting 59 + 60 + We use a variety of tools to format our code, and multiplex them with 61 + [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 + 58 64 ## proposals for bigger changes 59 65 60 66 Small fixes like typos, minor bugs, or trivial refactors can be ··· 115 121 If you're submitting a PR with multiple commits, make sure each one is 116 122 signed. 117 123 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 124 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 + to make it sign off commits in the tangled repo: 120 126 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 127 + ```shell 128 + # Safety check, should say "No matching config key..." 129 + jj config list templates.commit_trailers 130 + # The command below may need to be adjusted if the command above returned something. 131 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 132 ``` 134 133 135 134 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 135 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 136 for more information.
+59 -10
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 create a knot with hostname `localhost:6000`. This will 60 + generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 + don't lose it. 63 + 64 + You will also need to set the `$TANGLED_VM_SPINDLE_OWNER` 65 + variable to some value. If you don't want to [set up a 66 + spindle](#running-a-spindle), you can use any placeholder 67 + value. 45 68 46 - You can now start a lightweight NixOS VM using 47 - `nixos-shell` like so: 69 + You can now start a lightweight NixOS VM like so: 48 70 49 71 ```bash 50 - QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM 72 + nix run --impure .#vm 51 73 52 - # hit Ctrl-a + c + q to exit the VM 74 + # type `poweroff` at the shell to exit the VM 53 75 ``` 54 76 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: 77 + This starts a knot on port 6000, a spindle on port 6555 78 + with `ssh` exposed on port 2222. You can push repositories 79 + to this VM with this ssh config block on your main machine: 58 80 59 81 ```bash 60 82 Host nixos-shell ··· 70 92 git remote add local-dev git@nixos-shell:user/repo 71 93 git push local-dev main 72 94 ``` 95 + 96 + ## running a spindle 97 + 98 + You will need to find out your DID by entering your login handle into 99 + <https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID. 100 + 101 + The above VM should already be running a spindle on `localhost:6555`. 102 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 103 + and register a spindle with hostname `localhost:6555`. It should instantly 104 + be verified. You can then configure each repository to use this spindle 105 + and run CI jobs. 106 + 107 + Of interest when debugging spindles: 108 + 109 + ``` 110 + # service logs from journald: 111 + journalctl -xeu spindle 112 + 113 + # CI job logs from disk: 114 + ls /var/log/spindle 115 + 116 + # debugging spindle db: 117 + sqlite3 /var/lib/spindle/spindle.db 118 + 119 + # litecli has a nicer REPL interface: 120 + litecli /var/lib/spindle/spindle.db 121 + ```
+55 -4
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 59 59 EOF 60 60 ``` 61 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 62 68 Next, create the `git` user. We'll use the `git` user's home directory 63 69 to store repositories: 64 70 ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 + [/knots](https://tangled.sh/knots) page on Tangled. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git ··· 89 95 systemctl start knotserver 90 96 ``` 91 97 98 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 99 + knot. Here's an example configuration for Nginx: 100 + 101 + ``` 102 + server { 103 + listen 80; 104 + listen [::]:80; 105 + server_name knot.example.com; 106 + 107 + location / { 108 + proxy_pass http://localhost:5555; 109 + proxy_set_header Host $host; 110 + proxy_set_header X-Real-IP $remote_addr; 111 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 112 + proxy_set_header X-Forwarded-Proto $scheme; 113 + } 114 + 115 + # wss endpoint for git events 116 + location /events { 117 + proxy_set_header X-Forwarded-For $remote_addr; 118 + proxy_set_header Host $http_host; 119 + proxy_set_header Upgrade websocket; 120 + proxy_set_header Connection Upgrade; 121 + proxy_pass http://localhost:5555; 122 + } 123 + # additional config for SSL/TLS go here. 124 + } 125 + 126 + ``` 127 + 128 + Remember to use Let's Encrypt or similar to procure a certificate for your 129 + knot domain. 130 + 92 131 You should now have a running knot server! You can finalize your registration by hitting the 93 - `initialize` button on the [/knots](/knots) page. 132 + `initialize` button on the [/knots](https://tangled.sh/knots) page. 94 133 95 134 ### custom paths 96 135 ··· 158 197 ``` 159 198 160 199 Make sure to restart your SSH server! 200 + 201 + #### MOTD (message of the day) 202 + 203 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 204 + `/home/git/motd` file: 205 + 206 + ``` 207 + printf "Hi from this knot!\n" > /home/git/motd 208 + ``` 209 + 210 + Note that you should add a newline at the end if setting a non-empty message 211 + 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 -f -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 + ```
+33 -3
docs/spindle/pipeline.md
··· 4 4 repo. Generally: 5 5 6 6 * Pipelines are defined in YAML. 7 - * Dependencies can be specified from 8 - [Nixpkgs](https://search.nixos.org) or custom registries. 9 - * Environment variables can be set globally or per-step. 7 + * Workflows can run using different *engines*. 8 + 9 + The most barebones workflow looks like this: 10 + 11 + ```yaml 12 + when: 13 + - event: ["push"] 14 + branch: ["main"] 15 + 16 + engine: "nixery" 17 + 18 + # optional 19 + clone: 20 + skip: false 21 + depth: 50 22 + submodules: true 23 + ``` 24 + 25 + The `when` and `engine` fields are required, while every other aspect 26 + of how the definition is parsed is up to the engine. Currently, a spindle 27 + provides at least one of these built-in engines: 28 + 29 + ## `nixery` 30 + 31 + The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run 32 + steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs). 10 33 11 34 Here's an example that uses all fields: 12 35 ··· 57 80 depth: 50 58 81 submodules: true 59 82 ``` 83 + 84 + ## git push options 85 + 86 + These are push options that can be used with the `--push-option (-o)` flag of git push: 87 + 88 + - `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push. 89 + - `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+12 -1
eventconsumer/consumer.go
··· 172 172 func (c *Consumer) startConnectionLoop(ctx context.Context, source Source) { 173 173 defer c.wg.Done() 174 174 175 + // attempt connection initially 176 + err := c.runConnection(ctx, source) 177 + if err != nil { 178 + c.logger.Error("failed to run connection", "err", err) 179 + } 180 + 181 + timer := time.NewTimer(1 * time.Minute) 182 + defer timer.Stop() 183 + 184 + // every subsequent attempt is delayed by 1 minute 175 185 for { 176 186 select { 177 187 case <-ctx.Done(): 178 188 return 179 - default: 189 + case <-timer.C: 180 190 err := c.runConnection(ctx, source) 181 191 if err != nil { 182 192 c.logger.Error("failed to run connection", "err", err) 183 193 } 194 + timer.Reset(1 * time.Minute) 184 195 } 185 196 } 186 197 }
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 21 } 22 22 23 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 25 if err != nil { 26 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 27 }
+53 -19
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "gitignore": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1694529238, 9 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "gomod2nix": { 4 22 "inputs": { 23 + "flake-utils": "flake-utils", 5 24 "nixpkgs": [ 6 25 "nixpkgs" 7 26 ] 8 27 }, 9 28 "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 31 + "owner": "nix-community", 32 + "repo": "gomod2nix", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 15 34 "type": "github" 16 35 }, 17 36 "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 37 + "owner": "nix-community", 38 + "repo": "gomod2nix", 20 39 "type": "github" 21 40 } 22 41 }, ··· 60 79 "indigo": { 61 80 "flake": false, 62 81 "locked": { 63 - "lastModified": 1745333930, 64 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 82 + "lastModified": 1753693716, 83 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 65 84 "owner": "oppiliappan", 66 85 "repo": "indigo", 67 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 86 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 68 87 "type": "github" 69 88 }, 70 89 "original": { ··· 89 108 "lucide-src": { 90 109 "flake": false, 91 110 "locked": { 92 - "lastModified": 1742302029, 93 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 111 + "lastModified": 1754044466, 112 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 94 113 "type": "tarball", 95 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 114 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 96 115 }, 97 116 "original": { 98 117 "type": "tarball", 99 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 118 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 100 119 } 101 120 }, 102 121 "nixpkgs": { 103 122 "locked": { 104 - "lastModified": 1746904237, 105 - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", 123 + "lastModified": 1751984180, 124 + "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 106 125 "owner": "nixos", 107 126 "repo": "nixpkgs", 108 - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", 127 + "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 109 128 "type": "github" 110 129 }, 111 130 "original": { ··· 117 136 }, 118 137 "root": { 119 138 "inputs": { 120 - "gitignore": "gitignore", 139 + "gomod2nix": "gomod2nix", 121 140 "htmx-src": "htmx-src", 122 141 "htmx-ws-src": "htmx-ws-src", 123 142 "ibm-plex-mono-src": "ibm-plex-mono-src", ··· 139 158 "original": { 140 159 "type": "tarball", 141 160 "url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip" 161 + } 162 + }, 163 + "systems": { 164 + "locked": { 165 + "lastModified": 1681028828, 166 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 167 + "owner": "nix-systems", 168 + "repo": "default", 169 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 170 + "type": "github" 171 + }, 172 + "original": { 173 + "owner": "nix-systems", 174 + "repo": "default", 175 + "type": "github" 142 176 } 143 177 } 144 178 },
+183 -70
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; ··· 18 22 flake = false; 19 23 }; 20 24 lucide-src = { 21 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 22 26 flake = false; 23 27 }; 24 28 inter-fonts-src = { ··· 33 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 34 38 flake = false; 35 39 }; 36 - gitignore = { 37 - url = "github:hercules-ci/gitignore.nix"; 38 - inputs.nixpkgs.follows = "nixpkgs"; 39 - }; 40 40 }; 41 41 42 42 outputs = { 43 43 self, 44 44 nixpkgs, 45 + gomod2nix, 45 46 indigo, 46 47 htmx-src, 47 48 htmx-ws-src, 48 49 lucide-src, 49 - gitignore, 50 50 inter-fonts-src, 51 51 sqlite-lib-src, 52 52 ibm-plex-mono-src, 53 53 }: let 54 54 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 55 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 { 56 + nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 57 + 58 + mkPackageSet = pkgs: 59 + pkgs.lib.makeScope pkgs.newScope (self: { 60 + src = let 61 + fs = pkgs.lib.fileset; 62 + in 63 + fs.toSource { 64 + root = ./.; 65 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 66 + }; 67 + buildGoApplication = 68 + (self.callPackage "${gomod2nix}/builder" { 69 + gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 70 + }).buildGoApplication; 71 + modules = ./nix/gomod2nix.toml; 72 + sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 81 73 inherit (pkgs) gcc; 82 74 inherit sqlite-lib-src; 83 75 }; 84 - genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;}; 85 - }; 86 - in 87 - mkPackageSet final; 76 + genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 77 + lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 79 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 80 + }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 82 + spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 83 + knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 84 + knot = self.callPackage ./nix/pkgs/knot.nix {}; 85 + }); 86 + in { 87 + overlays.default = final: prev: { 88 + inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 89 + }; 88 90 89 91 packages = forAllSystems (system: let 90 92 pkgs = nixpkgsFor.${system}; 91 - staticPkgs = pkgs.pkgsStatic; 92 - crossPkgs = pkgs.pkgsCross.gnu64.pkgsStatic; 93 + packages = mkPackageSet pkgs; 94 + staticPackages = mkPackageSet pkgs.pkgsStatic; 95 + crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 93 96 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; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 98 + 99 + pkgsStatic-appview = staticPackages.appview; 100 + pkgsStatic-knot = staticPackages.knot; 101 + pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 102 + pkgsStatic-spindle = staticPackages.spindle; 103 + pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 104 + 105 + pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 106 + pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 107 + pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 108 + pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 109 + 110 + treefmt-wrapper = pkgs.treefmt.withConfig { 111 + settings.formatter = { 112 + alejandra = { 113 + command = pkgs.lib.getExe pkgs.alejandra; 114 + includes = ["*.nix"]; 115 + }; 101 116 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; 117 + gofmt = { 118 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 119 + options = ["-w"]; 120 + includes = ["*.go"]; 121 + }; 107 122 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; 123 + # prettier = let 124 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 125 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 126 + # ''; 127 + # in { 128 + # command = wrapper; 129 + # options = ["-w"]; 130 + # includes = ["*.html"]; 131 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 132 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 133 + # }; 134 + }; 135 + }; 112 136 }); 113 - defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview); 114 - formatter = forAllSystems (system: nixpkgsFor."${system}".alejandra); 137 + defaultPackage = forAllSystems (system: self.packages.${system}.appview); 115 138 devShells = forAllSystems (system: let 116 139 pkgs = nixpkgsFor.${system}; 140 + packages' = self.packages.${system}; 117 141 staticShell = pkgs.mkShell.override { 118 142 stdenv = pkgs.pkgsStatic.stdenv; 119 143 }; ··· 124 148 pkgs.air 125 149 pkgs.gopls 126 150 pkgs.httpie 127 - pkgs.lexgen 128 151 pkgs.litecli 129 152 pkgs.websocat 130 153 pkgs.tailwindcss 131 154 pkgs.nixos-shell 132 155 pkgs.redis 156 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 157 + packages'.lexgen 158 + packages'.treefmt-wrapper 133 159 ]; 134 160 shellHook = '' 135 - mkdir -p appview/pages/static/{fonts,icons} 136 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 137 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 138 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 139 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 140 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 141 - 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)" 161 + mkdir -p appview/pages/static 162 + # no preserve is needed because watch-tailwind will want to be able to overwrite 163 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 164 + export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 143 165 ''; 144 166 env.CGO_ENABLED = 1; 145 167 }; 146 168 }); 147 169 apps = forAllSystems (system: let 148 170 pkgs = nixpkgsFor."${system}"; 171 + packages' = self.packages.${system}; 149 172 air-watcher = name: arg: 150 173 pkgs.writeShellScriptBin "run" 151 174 '' 152 175 ${pkgs.air}/bin/air -c /dev/null \ 153 176 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 154 - -build.bin "./out/${name}.out ${arg}" \ 177 + -build.bin "./out/${name}.out" \ 178 + -build.args_bin "${arg}" \ 155 179 -build.stop_on_error "true" \ 156 180 -build.include_ext "go" 157 181 ''; ··· 161 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 162 186 ''; 163 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 164 192 watch-appview = { 165 193 type = "app"; 166 - program = ''${air-watcher "appview" ""}/bin/run''; 194 + program = toString (pkgs.writeShellScript "watch-appview" '' 195 + echo "copying static files to appview/pages/static..." 196 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 197 + ${air-watcher "appview" ""}/bin/run 198 + ''); 167 199 }; 168 200 watch-knot = { 169 201 type = "app"; ··· 173 205 type = "app"; 174 206 program = ''${tailwind-watcher}/bin/run''; 175 207 }; 208 + vm = let 209 + guestSystem = 210 + if pkgs.stdenv.hostPlatform.isAarch64 211 + then "aarch64-linux" 212 + else "x86_64-linux"; 213 + in { 214 + type = "app"; 215 + program = 216 + (pkgs.writeShellApplication { 217 + name = "launch-vm"; 218 + text = '' 219 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 220 + cd "$rootDir" 221 + 222 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 223 + 224 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 225 + exec ${pkgs.lib.getExe 226 + (import ./nix/vm.nix { 227 + inherit nixpkgs self; 228 + system = guestSystem; 229 + hostSystem = system; 230 + }).config.system.build.vm} 231 + ''; 232 + }) 233 + + /bin/launch-vm; 234 + }; 235 + gomod2nix = { 236 + type = "app"; 237 + program = toString (pkgs.writeShellScript "gomod2nix" '' 238 + ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 239 + ''); 240 + }; 241 + lexgen = { 242 + type = "app"; 243 + program = 244 + (pkgs.writeShellApplication { 245 + name = "lexgen"; 246 + text = '' 247 + if ! command -v lexgen > /dev/null; then 248 + echo "error: must be executed from devshell" 249 + exit 1 250 + fi 251 + 252 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 + cd "$rootDir" 254 + 255 + rm api/tangled/* 256 + lexgen --build-file lexicon-build-config.json lexicons 257 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 258 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 259 + go run cmd/gen.go 260 + lexgen --build-file lexicon-build-config.json lexicons 261 + rm api/tangled/*.bak 262 + ''; 263 + }) 264 + + /bin/lexgen; 265 + }; 176 266 }); 177 267 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;}; 181 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 268 + nixosModules.appview = { 269 + lib, 270 + pkgs, 271 + ... 272 + }: { 273 + imports = [./nix/modules/appview.nix]; 274 + 275 + services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 276 + }; 277 + nixosModules.knot = { 278 + lib, 279 + pkgs, 280 + ... 281 + }: { 282 + imports = [./nix/modules/knot.nix]; 283 + 284 + services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 285 + }; 286 + nixosModules.spindle = { 287 + lib, 288 + pkgs, 289 + ... 290 + }: { 291 + imports = [./nix/modules/spindle.nix]; 292 + 293 + services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 294 + }; 182 295 }; 183 296 }
+57 -35
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 ··· 21 22 github.com/go-enry/go-enry/v2 v2.9.2 22 23 github.com/go-git/go-git/v5 v5.14.0 23 24 github.com/google/uuid v1.6.0 25 + github.com/gorilla/feeds v1.2.0 24 26 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 27 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 28 github.com/hiddeco/sshsig v0.2.0 27 29 github.com/hpcloud/tail v1.0.0 28 30 github.com/ipfs/go-cid v0.5.0 29 31 github.com/lestrrat-go/jwx/v2 v2.1.6 30 32 github.com/mattn/go-sqlite3 v1.14.24 31 33 github.com/microcosm-cc/bluemonday v1.0.27 34 + github.com/openbao/openbao/api/v2 v2.3.0 32 35 github.com/posthog/posthog-go v1.5.5 33 - github.com/redis/go-redis/v9 v9.3.0 36 + github.com/redis/go-redis/v9 v9.7.3 34 37 github.com/resend/resend-go/v2 v2.15.0 35 38 github.com/sethvargo/go-envconfig v1.1.0 36 39 github.com/stretchr/testify v1.10.0 37 40 github.com/urfave/cli/v3 v3.3.3 38 41 github.com/whyrusleeping/cbor-gen v0.3.1 39 - github.com/yuin/goldmark v1.4.13 40 - golang.org/x/crypto v0.38.0 41 - golang.org/x/net v0.40.0 42 + github.com/yuin/goldmark v1.4.15 43 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 44 + golang.org/x/crypto v0.40.0 45 + golang.org/x/net v0.42.0 46 + golang.org/x/sync v0.16.0 42 47 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 43 48 gopkg.in/yaml.v3 v3.0.1 44 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 49 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 45 50 ) 46 51 47 52 require ( 48 53 dario.cat/mergo v1.0.1 // indirect 49 54 github.com/Microsoft/go-winio v0.6.2 // indirect 50 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 55 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 56 + github.com/alecthomas/repr v0.4.0 // indirect 51 57 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 52 - github.com/avast/retry-go/v4 v4.6.1 // indirect 53 58 github.com/aymerick/douceur v0.2.0 // indirect 54 59 github.com/beorn7/perks v1.0.1 // indirect 55 60 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 61 github.com/casbin/govaluate v1.3.0 // indirect 62 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 57 63 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 64 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 59 65 github.com/containerd/errdefs v1.0.0 // indirect 60 66 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 67 github.com/containerd/log v0.1.0 // indirect ··· 68 74 github.com/docker/go-units v0.5.0 // indirect 69 75 github.com/emirpasic/gods v1.18.1 // indirect 70 76 github.com/felixge/httpsnoop v1.0.4 // indirect 77 + github.com/fsnotify/fsnotify v1.6.0 // indirect 71 78 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 79 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 80 github.com/go-git/go-billy/v5 v5.6.2 // indirect 74 - github.com/go-logr/logr v1.4.2 // indirect 81 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 82 + github.com/go-logr/logr v1.4.3 // indirect 75 83 github.com/go-logr/stdr v1.2.2 // indirect 76 84 github.com/go-redis/cache/v9 v9.0.0 // indirect 85 + github.com/go-test/deep v1.1.1 // indirect 77 86 github.com/goccy/go-json v0.10.5 // indirect 78 87 github.com/gogo/protobuf v1.3.2 // indirect 79 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 88 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 80 89 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 90 + github.com/golang/mock v1.6.0 // indirect 91 + github.com/google/go-querystring v1.1.0 // indirect 81 92 github.com/gorilla/css v1.0.1 // indirect 82 93 github.com/gorilla/securecookie v1.1.2 // indirect 94 + github.com/hashicorp/errwrap v1.1.0 // indirect 83 95 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 96 + github.com/hashicorp/go-multierror v1.1.1 // indirect 97 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 98 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 99 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 100 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 85 101 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 102 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 103 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 104 + github.com/hexops/gotextdiff v1.0.3 // indirect 87 105 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 106 + github.com/ipfs/boxo v0.33.0 // indirect 107 + github.com/ipfs/go-block-format v0.2.2 // indirect 90 108 github.com/ipfs/go-datastore v0.8.2 // indirect 91 109 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 92 110 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 111 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 112 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 95 113 github.com/ipfs/go-log v1.0.5 // indirect 96 114 github.com/ipfs/go-log/v2 v2.6.0 // indirect 97 115 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 98 116 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 117 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 118 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 119 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 102 120 github.com/lestrrat-go/httpcc v1.0.1 // indirect 103 121 github.com/lestrrat-go/httprc v1.0.6 // indirect 104 122 github.com/lestrrat-go/iter v1.0.2 // indirect 105 123 github.com/lestrrat-go/option v1.0.1 // indirect 106 124 github.com/mattn/go-isatty v0.0.20 // indirect 107 125 github.com/minio/sha256-simd v1.0.1 // indirect 126 + github.com/mitchellh/mapstructure v1.5.0 // indirect 108 127 github.com/moby/docker-image-spec v1.3.1 // indirect 109 128 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 129 github.com/moby/term v0.5.2 // indirect ··· 116 135 github.com/multiformats/go-multihash v0.2.3 // indirect 117 136 github.com/multiformats/go-varint v0.0.7 // indirect 118 137 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 138 + github.com/onsi/gomega v1.37.0 // indirect 119 139 github.com/opencontainers/go-digest v1.0.0 // indirect 120 140 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 141 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 122 142 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 143 github.com/pkg/errors v0.9.1 // indirect 124 144 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 125 145 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 126 146 github.com/prometheus/client_golang v1.22.0 // indirect 127 147 github.com/prometheus/client_model v0.6.2 // indirect 128 - github.com/prometheus/common v0.63.0 // indirect 148 + github.com/prometheus/common v0.64.0 // indirect 129 149 github.com/prometheus/procfs v0.16.1 // indirect 150 + github.com/ryanuber/go-glob v1.0.0 // indirect 130 151 github.com/segmentio/asm v1.2.0 // indirect 131 152 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 153 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 136 157 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 137 158 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 138 159 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 160 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 161 + go.opentelemetry.io/otel v1.37.0 // indirect 162 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 163 + go.opentelemetry.io/otel/metric v1.37.0 // indirect 164 + go.opentelemetry.io/otel/trace v1.37.0 // indirect 143 165 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 144 166 go.uber.org/atomic v1.11.0 // indirect 145 167 go.uber.org/multierr v1.11.0 // indirect 146 168 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 169 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 170 + golang.org/x/sys v0.34.0 // indirect 171 + golang.org/x/text v0.27.0 // indirect 172 + golang.org/x/time v0.12.0 // indirect 173 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 174 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 175 + google.golang.org/grpc v1.73.0 // indirect 154 176 google.golang.org/protobuf v1.36.6 // indirect 155 177 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 178 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+136 -88
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= ··· 77 79 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 78 80 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 79 81 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 82 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 80 83 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 81 84 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 82 85 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 91 94 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 92 95 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 93 96 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= 97 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 98 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 96 99 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 97 100 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 98 101 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 102 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 103 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 104 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 101 105 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 102 106 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 103 107 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 114 118 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 115 119 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 116 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 117 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 118 124 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= 125 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 126 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 121 127 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 122 128 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 123 129 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 124 130 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 125 131 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 132 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 133 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 126 134 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 127 135 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 128 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 129 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 130 138 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= 139 + github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 + github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 133 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 134 142 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 143 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 144 + github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 145 + github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 137 146 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 138 147 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 139 148 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 146 155 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 156 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 157 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 158 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 159 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 160 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 161 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 162 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 163 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 164 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 165 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 154 166 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 167 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 168 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 162 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 163 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 164 176 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 177 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 178 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 165 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 166 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 167 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 168 182 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= 183 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 184 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 171 185 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 172 186 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 187 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 188 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 189 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 173 190 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 174 191 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 175 192 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 176 193 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= 194 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 195 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 196 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 197 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 198 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 199 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 200 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 201 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 202 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 203 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 179 204 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 180 205 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 181 206 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 182 207 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 208 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 209 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 183 210 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 184 211 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 185 212 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 189 216 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 190 217 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 191 218 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= 219 + github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 220 + github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 221 + github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 222 + github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 196 223 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 197 224 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 198 225 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 205 232 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 206 233 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 207 234 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= 235 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 236 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 237 + github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 238 + github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 212 239 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 213 240 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 214 241 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 216 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 217 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 218 245 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 246 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 222 247 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 223 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 229 254 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 230 255 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 231 256 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= 257 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 258 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 234 259 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 235 260 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 236 261 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 239 264 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 240 265 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 241 266 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= 267 + github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 268 + github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 244 269 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 245 270 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 246 271 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 251 276 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 252 277 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 253 278 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= 279 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 260 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 261 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 262 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 265 286 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 287 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 267 288 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 289 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 290 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 268 291 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 269 292 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 270 293 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 281 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 282 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 283 306 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 307 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 287 308 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 309 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 310 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 311 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 318 337 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 338 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 339 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= 340 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 341 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 342 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 343 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 323 344 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 324 345 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 325 346 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 326 347 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 348 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 349 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 350 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 329 351 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 330 352 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 331 353 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 346 368 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 347 369 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 348 370 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= 371 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 372 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 351 373 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 352 374 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 353 375 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= 376 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 377 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 356 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 357 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 358 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 360 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 361 383 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 362 384 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 385 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 386 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 363 387 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 364 388 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 365 389 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 404 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 405 429 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 406 430 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 431 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 407 432 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 408 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 409 433 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 434 + github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= 435 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 436 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 437 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 410 438 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 411 439 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 412 440 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 413 441 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 414 442 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 415 443 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= 444 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= 445 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= 446 + go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 447 + go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 448 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 449 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 422 450 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 423 451 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= 452 + go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 453 + go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 454 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 455 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 456 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 457 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 458 + go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 459 + go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 432 460 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 433 461 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 434 462 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 451 479 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 452 480 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 453 481 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= 482 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 483 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 484 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 485 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 486 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 458 487 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 459 488 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 460 489 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 461 490 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 491 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 462 492 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 463 493 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 464 494 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 465 495 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 496 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 466 497 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 467 498 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 468 499 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 471 502 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 472 503 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 473 504 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 505 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 474 506 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 475 507 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 476 508 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 480 512 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 481 513 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 482 514 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= 515 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 516 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 517 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 518 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 485 519 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 486 520 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 521 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 489 523 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 524 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 525 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= 526 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 527 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 494 528 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 495 529 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 496 530 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 502 536 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 537 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 538 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 539 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 505 540 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 541 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 506 542 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 543 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 544 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 510 546 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 547 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 548 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 549 + golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 550 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 551 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 552 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 553 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 554 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 517 555 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= 556 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 557 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 558 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 559 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 520 560 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 521 561 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 522 562 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 523 563 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 524 564 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 525 565 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= 566 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 567 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 568 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 569 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 570 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 528 571 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 529 572 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 530 573 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 532 575 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 533 576 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 534 577 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= 578 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 579 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 580 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 581 + golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 582 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 583 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 584 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 539 585 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 586 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 587 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 547 593 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 548 594 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 549 595 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 596 + golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 550 597 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 551 598 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 552 599 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 553 600 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 601 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 554 602 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 603 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 556 604 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 557 605 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 558 606 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 559 607 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= 608 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= 609 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= 610 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= 611 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 612 + google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 613 + google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 566 614 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 567 615 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 568 616 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 599 647 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 600 648 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 601 649 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= 650 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 651 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 604 652 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 605 653 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 + }
+84 -8
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 32 40 font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 48 + font-weight: normal; 49 + font-style: italic; 50 + font-display: swap; 51 + } 52 + 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 33 65 font-style: italic; 34 66 font-display: swap; 35 67 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 70 101 details summary::-webkit-details-marker { 71 102 display: none; 72 103 } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + } 73 108 } 74 109 75 110 @layer components { ··· 98 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 99 134 } 100 135 136 + .prose hr { 137 + @apply my-2; 138 + } 139 + 140 + .prose li:has(input) { 141 + @apply list-none; 142 + } 143 + 144 + .prose ul:has(input) { 145 + @apply pl-2; 146 + } 147 + 148 + .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 + } 151 + 152 + .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 + } 155 + 156 + .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 + } 159 + 160 + .prose a.footnote-backref { 161 + @apply no-underline; 162 + } 163 + 164 + .prose li { 165 + @apply my-0 py-0; 166 + } 167 + 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 170 + } 171 + 101 172 .prose img { 102 173 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 174 + margin: 0; 105 175 vertical-align: middle; 176 + } 177 + 178 + .prose input { 179 + @apply inline-block my-0 mb-1 mx-1; 180 + } 181 + 182 + .prose input[type="checkbox"] { 183 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 106 184 } 107 185 } 108 186 @layer utilities { ··· 123 201 /* PreWrapper */ 124 202 .chroma { 125 203 color: #4c4f69; 126 - background-color: #eff1f5; 127 204 } 128 205 /* Error */ 129 206 .chroma .err { ··· 460 537 /* PreWrapper */ 461 538 .chroma { 462 539 color: #cad3f5; 463 - background-color: #24273a; 464 540 } 465 541 /* Error */ 466 542 .chroma .err {
+19 -4
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 { 58 - // empty filter => all dids allowed 59 - if len(j.wantedDids) == 0 { 60 - return processFunc 61 - } 62 71 // since this closure references j.WantedDids; it should auto-update 63 72 // existing instances of the closure when j.WantedDids is mutated 64 73 return func(ctx context.Context, evt *models.Event) error { 74 + 75 + // empty filter => all dids allowed 76 + if len(j.wantedDids) == 0 { 77 + return processFunc(ctx, evt) 78 + } 79 + 65 80 if _, ok := j.wantedDids[evt.Did]; ok { 66 81 return processFunc(ctx, evt) 67 82 } else {
+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 {
+14 -10
knotserver/db/init.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 28 31 32 + _, err = db.Exec(` 29 33 create table if not exists known_dids ( 30 34 did text primary key 31 35 );
-8
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+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 + }
+8 -10
knotserver/git/fork.go
··· 10 10 ) 11 11 12 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 19 15 return fmt.Errorf("failed to bare clone repository: %w", err) 20 16 } 21 17 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 24 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 21 } 26 22 27 23 return nil 28 24 } 29 25 30 - func (g *GitRepo) Sync(branch string) error { 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 31 29 fetchOpts := &git.FetchOptions{ 32 30 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 34 32 }, 35 33 } 36 34
+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 + }
+69 -30
knotserver/git/post_receive.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 6 + "errors" 5 7 "fmt" 6 8 "io" 7 9 "strings" 10 + "time" 8 11 9 12 "tangled.sh/tangled.sh/core/api/tangled" 10 13 ··· 46 49 } 47 50 48 51 type RefUpdateMeta struct { 49 - CommitCount CommitCount 50 - IsDefaultRef bool 52 + CommitCount CommitCount 53 + IsDefaultRef bool 54 + LangBreakdown LangBreakdown 51 55 } 52 56 53 57 type CommitCount struct { 54 58 ByEmail map[string]int 55 59 } 56 60 57 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 58 64 commitCount, err := g.newCommitCount(line) 59 - if err != nil { 60 - // TODO: non-fatal, log this 61 - } 65 + errors.Join(errs, err) 62 66 63 67 isDefaultRef, err := g.isDefaultBranch(line) 64 - if err != nil { 65 - // TODO: non-fatal, log this 66 - } 68 + errors.Join(errs, err) 69 + 70 + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 71 + defer cancel() 72 + breakdown, err := g.AnalyzeLanguages(ctx) 73 + errors.Join(errs, err) 67 74 68 75 return RefUpdateMeta{ 69 - CommitCount: commitCount, 70 - IsDefaultRef: isDefaultRef, 71 - } 76 + CommitCount: commitCount, 77 + IsDefaultRef: isDefaultRef, 78 + LangBreakdown: breakdown, 79 + }, errs 72 80 } 73 81 74 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 77 85 ByEmail: byEmail, 78 86 } 79 87 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) 88 + if line.NewSha.IsZero() { 89 + return commitCount, nil 90 + } 91 + 92 + args := []string{fmt.Sprintf("--max-count=%d", 100)} 93 + 94 + if line.OldSha.IsZero() { 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 96 + args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 87 103 } 88 104 89 - lines := strings.Split(strings.TrimSpace(string(output)), "\n") 90 - if len(lines) == 1 && lines[0] == "" { 91 - return commitCount, nil 92 - } 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 107 + } else { 108 + // git rev-list <oldsha>..<newsha> 109 + args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String())) 110 + } 111 + 112 + output, err := g.revList(args...) 113 + if err != nil { 114 + return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 115 + } 93 116 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 117 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 118 + if len(lines) == 1 && lines[0] == "" { 119 + return commitCount, nil 120 + } 121 + 122 + for _, item := range lines { 123 + obj, err := g.r.CommitObject(plumbing.NewHash(item)) 124 + if err != nil { 125 + continue 100 126 } 127 + commitCount.ByEmail[obj.Author.Email] += 1 101 128 } 102 129 103 130 return commitCount, nil ··· 126 153 }) 127 154 } 128 155 156 + var langs []*tangled.GitRefUpdate_Pair 157 + for lang, size := range m.LangBreakdown { 158 + langs = append(langs, &tangled.GitRefUpdate_Pair{ 159 + Lang: lang, 160 + Size: size, 161 + }) 162 + } 163 + langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 164 + Inputs: langs, 165 + } 166 + 129 167 return tangled.GitRefUpdate_Meta{ 130 168 CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 131 169 ByEmail: byEmail, 132 170 }, 133 - IsDefaultRef: m.IsDefaultRef, 171 + IsDefaultRef: m.IsDefaultRef, 172 + LangBreakdown: langBreakdown, 134 173 } 135 174 }
+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 + }
+5
knotserver/git.go
··· 129 129 // If the appview gave us the repository owner's handle we can attempt to 130 130 // construct the correct ssh url. 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 132 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 134 hostname := d.c.Server.Hostname 134 135 if strings.Contains(hostname, ":") { 135 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 136 141 } 137 142 138 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+44 -25
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 19 ) 17 20 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 - ) 21 - 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 - 54 - err = h.jc.StartJetstream(ctx, h.processMessages) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 57 53 } 58 54 59 55 // Check if the knot knows about any Dids; ··· 70 66 for _, d := range dids { 71 67 h.jc.AddDid(d) 72 68 } 69 + } 70 + 71 + err = h.jc.StartJetstream(ctx, h.processMessages) 72 + if err != nil { 73 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 73 74 } 74 75 75 76 r.Get("/", h.Index) ··· 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) ··· 138 142 r.Delete("/", h.RemoveRepo) 139 143 r.Route("/fork", func(r chi.Router) { 140 144 r.Post("/", h.RepoFork) 141 - r.Post("/sync/{branch}", h.RepoForkSync) 142 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 145 + r.Post("/sync/*", h.RepoForkSync) 146 + r.Get("/sync/*", h.RepoForkAheadBehind) 143 147 }) 144 148 }) 145 149 ··· 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.
+106 -45
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 27 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 28 + func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 28 29 l := log.FromContext(ctx) 30 + raw := json.RawMessage(event.Commit.Record) 31 + did := event.Did 32 + 33 + var record tangled.PublicKey 34 + if err := json.Unmarshal(raw, &record); err != nil { 35 + return fmt.Errorf("failed to unmarshal record: %w", err) 36 + } 37 + 29 38 pk := db.PublicKey{ 30 39 Did: did, 31 40 PublicKey: record, ··· 38 47 return nil 39 48 } 40 49 41 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 50 + func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 42 51 l := log.FromContext(ctx) 52 + raw := json.RawMessage(event.Commit.Record) 53 + did := event.Did 54 + 55 + var record tangled.KnotMember 56 + if err := json.Unmarshal(raw, &record); err != nil { 57 + return fmt.Errorf("failed to unmarshal record: %w", err) 58 + } 43 59 44 60 if record.Domain != h.c.Server.Hostname { 45 61 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) 46 62 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 47 63 } 48 64 49 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 65 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 50 66 if err != nil || !ok { 51 67 l.Error("failed to add member", "did", did) 52 68 return fmt.Errorf("failed to enforce permissions: %w", err) 53 69 } 54 70 55 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 71 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 56 72 l.Error("failed to add member", "error", err) 57 73 return fmt.Errorf("failed to add member: %w", err) 58 74 } ··· 71 87 return nil 72 88 } 73 89 74 - func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error { 90 + func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 91 + raw := json.RawMessage(event.Commit.Record) 92 + did := event.Did 93 + 94 + var record tangled.RepoPull 95 + if err := json.Unmarshal(raw, &record); err != nil { 96 + return fmt.Errorf("failed to unmarshal record: %w", err) 97 + } 98 + 75 99 l := log.FromContext(ctx) 76 100 l = l.With("handler", "processPull") 77 101 l = l.With("did", did) ··· 151 175 return err 152 176 } 153 177 154 - var pipeline workflow.Pipeline 178 + var pipeline workflow.RawPipeline 155 179 for _, e := range workflowDir { 156 180 if !e.IsFile { 157 181 continue ··· 163 187 continue 164 188 } 165 189 166 - wf, err := workflow.FromFile(e.Name, contents) 167 - if err != nil { 168 - // TODO: log here, respond to client that is pushing 169 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 170 - continue 171 - } 172 - 173 - pipeline = append(pipeline, wf) 190 + pipeline = append(pipeline, workflow.RawWorkflow{ 191 + Name: e.Name, 192 + Contents: contents, 193 + }) 174 194 } 175 195 176 196 trigger := tangled.Pipeline_PullRequestTriggerData{ ··· 192 212 }, 193 213 } 194 214 195 - cp := compiler.Compile(pipeline) 215 + cp := compiler.Compile(compiler.Parse(pipeline)) 196 216 eventJson, err := json.Marshal(cp) 197 217 if err != nil { 198 218 return err ··· 203 223 return nil 204 224 } 205 225 206 - event := db.Event{ 226 + ev := db.Event{ 207 227 Rkey: TID(), 208 228 Nsid: tangled.PipelineNSID, 209 229 EventJson: string(eventJson), 210 230 } 211 231 212 - return h.db.InsertEvent(event, h.n) 232 + return h.db.InsertEvent(ev, h.n) 233 + } 234 + 235 + // duplicated from add collaborator 236 + func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 237 + raw := json.RawMessage(event.Commit.Record) 238 + did := event.Did 239 + 240 + var record tangled.RepoCollaborator 241 + if err := json.Unmarshal(raw, &record); err != nil { 242 + return fmt.Errorf("failed to unmarshal record: %w", err) 243 + } 244 + 245 + repoAt, err := syntax.ParseATURI(record.Repo) 246 + if err != nil { 247 + return err 248 + } 249 + 250 + resolver := idresolver.DefaultResolver() 251 + 252 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 253 + if err != nil || subjectId.Handle.IsInvalidHandle() { 254 + return err 255 + } 256 + 257 + // TODO: fix this for good, we need to fetch the record here unfortunately 258 + // resolve this aturi to extract the repo record 259 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 260 + if err != nil || owner.Handle.IsInvalidHandle() { 261 + return fmt.Errorf("failed to resolve handle: %w", err) 262 + } 263 + 264 + xrpcc := xrpc.Client{ 265 + Host: owner.PDSEndpoint(), 266 + } 267 + 268 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 269 + if err != nil { 270 + return err 271 + } 272 + 273 + repo := resp.Value.Val.(*tangled.Repo) 274 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 275 + 276 + // check perms for this user 277 + if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil { 278 + return fmt.Errorf("insufficient permissions: %w", err) 279 + } 280 + 281 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 282 + return err 283 + } 284 + h.jc.AddDid(subjectId.DID.String()) 285 + 286 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 287 + return err 288 + } 289 + 290 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 213 291 } 214 292 215 293 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { ··· 256 334 } 257 335 258 336 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 259 - did := event.Did 260 337 if event.Kind != models.EventKindCommit { 261 338 return nil 262 339 } ··· 265 342 defer func() { 266 343 eventTime := event.TimeUS 267 344 lastTimeUs := eventTime + 1 268 - fmt.Println("lastTimeUs", lastTimeUs) 269 345 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 270 346 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 271 347 } 272 348 }() 273 349 274 - raw := json.RawMessage(event.Commit.Record) 275 - 276 350 switch event.Commit.Collection { 277 351 case tangled.PublicKeyNSID: 278 - var record tangled.PublicKey 279 - if err := json.Unmarshal(raw, &record); err != nil { 280 - return fmt.Errorf("failed to unmarshal record: %w", err) 281 - } 282 - if err := h.processPublicKey(ctx, did, record); err != nil { 283 - return fmt.Errorf("failed to process public key: %w", err) 284 - } 285 - 352 + err = h.processPublicKey(ctx, event) 286 353 case tangled.KnotMemberNSID: 287 - var record tangled.KnotMember 288 - if err := json.Unmarshal(raw, &record); err != nil { 289 - return fmt.Errorf("failed to unmarshal record: %w", err) 290 - } 291 - if err := h.processKnotMember(ctx, did, record); err != nil { 292 - return fmt.Errorf("failed to process knot member: %w", err) 293 - } 354 + err = h.processKnotMember(ctx, event) 294 355 case tangled.RepoPullNSID: 295 - var record tangled.RepoPull 296 - if err := json.Unmarshal(raw, &record); err != nil { 297 - return fmt.Errorf("failed to unmarshal record: %w", err) 298 - } 299 - if err := h.processPull(ctx, did, record); err != nil { 300 - return fmt.Errorf("failed to process knot member: %w", err) 301 - } 356 + err = h.processPull(ctx, event) 357 + case tangled.RepoCollaboratorNSID: 358 + err = h.processCollaborator(ctx, event) 302 359 } 303 360 304 - return err 361 + if err != nil { 362 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 363 + } 364 + 365 + return nil 305 366 }
+60 -18
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 7 + "fmt" 6 8 "log/slog" 7 9 "net/http" 8 10 "path/filepath" ··· 12 14 "github.com/go-chi/chi/v5" 13 15 "github.com/go-chi/chi/v5/middleware" 14 16 "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 15 18 "tangled.sh/tangled.sh/core/knotserver/config" 16 19 "tangled.sh/tangled.sh/core/knotserver/db" 17 20 "tangled.sh/tangled.sh/core/knotserver/git" ··· 37 40 return 38 41 } 39 42 40 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 43 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 41 44 if err != nil || !ok { 42 45 w.WriteHeader(http.StatusForbidden) 43 46 return ··· 61 64 } 62 65 writeJSON(w, data) 63 66 return 67 + } 68 + 69 + type PushOptions struct { 70 + skipCi bool 71 + verboseCi bool 64 72 } 65 73 66 74 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { ··· 89 97 // non-fatal 90 98 } 91 99 100 + // extract any push options 101 + pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 102 + pushOptions := PushOptions{} 103 + for _, option := range pushOptionsRaw { 104 + if option == "skip-ci" || option == "ci-skip" { 105 + pushOptions.skipCi = true 106 + } 107 + if option == "verbose-ci" || option == "ci-verbose" { 108 + pushOptions.verboseCi = true 109 + } 110 + } 111 + 112 + resp := hook.HookResponse{ 113 + Messages: make([]string, 0), 114 + } 115 + 92 116 for _, line := range lines { 93 117 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 94 118 if err != nil { ··· 96 120 // non-fatal 97 121 } 98 122 99 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 123 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 100 124 if err != nil { 101 125 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 102 126 // non-fatal 103 127 } 104 128 } 129 + 130 + writeJSON(w, resp) 105 131 } 106 132 107 133 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 115 141 return err 116 142 } 117 143 118 - gr, err := git.PlainOpen(repoPath) 144 + gr, err := git.Open(repoPath, line.Ref) 119 145 if err != nil { 120 - return err 146 + return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 121 147 } 122 148 123 - meta := gr.RefUpdateMeta(line) 149 + var errs error 150 + meta, err := gr.RefUpdateMeta(line) 151 + errors.Join(errs, err) 152 + 124 153 metaRecord := meta.AsRecord() 125 154 126 155 refUpdate := tangled.GitRefUpdate{ ··· 143 172 EventJson: string(eventJson), 144 173 } 145 174 146 - return h.db.InsertEvent(event, h.n) 175 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 147 176 } 148 177 149 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 178 + func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 179 + if pushOptions.skipCi { 180 + return nil 181 + } 182 + 150 183 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 151 184 if err != nil { 152 185 return err ··· 167 200 return err 168 201 } 169 202 170 - var pipeline workflow.Pipeline 203 + var pipeline workflow.RawPipeline 171 204 for _, e := range workflowDir { 172 205 if !e.IsFile { 173 206 continue ··· 179 212 continue 180 213 } 181 214 182 - wf, err := workflow.FromFile(e.Name, contents) 183 - if err != nil { 184 - // TODO: log here, respond to client that is pushing 185 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 186 - continue 187 - } 188 - 189 - pipeline = append(pipeline, wf) 215 + pipeline = append(pipeline, workflow.RawWorkflow{ 216 + Name: e.Name, 217 + Contents: contents, 218 + }) 190 219 } 191 220 192 221 trigger := tangled.Pipeline_PushTriggerData{ ··· 207 236 }, 208 237 } 209 238 210 - // TODO: send the diagnostics back to the user here via stderr 211 - cp := compiler.Compile(pipeline) 239 + cp := compiler.Compile(compiler.Parse(pipeline)) 212 240 eventJson, err := json.Marshal(cp) 213 241 if err != nil { 214 242 return err 243 + } 244 + 245 + for _, e := range compiler.Diagnostics.Errors { 246 + *clientMsgs = append(*clientMsgs, e.String()) 247 + } 248 + 249 + if pushOptions.verboseCi { 250 + if compiler.Diagnostics.IsEmpty() { 251 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 252 + } 253 + 254 + for _, w := range compiler.Diagnostics.Warnings { 255 + *clientMsgs = append(*clientMsgs, w.String()) 256 + } 215 257 } 216 258 217 259 // do not run empty pipelines
+60 -79
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 + contentHash := sha256.Sum256(contents) 290 + eTag := fmt.Sprintf("\"%x\"", contentHash) 291 + 292 + // allow image, video, and text/plain files to be served directly 293 + switch { 294 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 295 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 296 + w.WriteHeader(http.StatusNotModified) 297 + return 298 + } 299 + w.Header().Set("ETag", eTag) 300 + 301 + case strings.HasPrefix(mimeType, "text/plain"): 302 + w.Header().Set("Cache-Control", "public, no-cache") 303 + 304 + default: 305 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 306 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 289 307 return 290 308 } 291 309 292 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 293 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 294 310 w.Header().Set("Content-Type", mimeType) 295 311 w.Write(contents) 296 312 } ··· 350 366 351 367 ref := strings.TrimSuffix(file, ".tar.gz") 352 368 369 + unescapedRef, err := url.PathUnescape(ref) 370 + if err != nil { 371 + notFound(w) 372 + return 373 + } 374 + 375 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 376 + 353 377 // This allows the browser to use a proper name for the file when 354 378 // downloading 355 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 379 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 356 380 setContentDisposition(w, filename) 357 381 setGZipMIME(w) 358 382 359 383 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 360 - gr, err := git.Open(path, ref) 384 + gr, err := git.Open(path, unescapedRef) 361 385 if err != nil { 362 386 notFound(w) 363 387 return ··· 366 390 gw := gzip.NewWriter(w) 367 391 defer gw.Close() 368 392 369 - prefix := fmt.Sprintf("%s-%s", name, ref) 393 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 370 394 err = gr.WriteTar(gw, prefix) 371 395 if err != nil { 372 396 // once we start writing to the body we can't report error anymore ··· 488 512 489 513 rtags := []*types.TagReference{} 490 514 for _, tag := range tags { 515 + var target *object.Tag 516 + if tag.Target != plumbing.ZeroHash { 517 + target = &tag 518 + } 491 519 tr := types.TagReference{ 492 - Tag: tag.TagObject(), 520 + Tag: target, 493 521 } 494 522 495 523 tr.Reference = types.Reference{ 496 - Name: tag.Name(), 497 - Hash: tag.Hash().String(), 524 + Name: tag.Name, 525 + Hash: tag.Hash.String(), 498 526 } 499 527 500 - if tag.Message() != "" { 501 - tr.Message = tag.Message() 528 + if tag.Message != "" { 529 + tr.Message = tag.Message 502 530 } 503 531 504 532 rtags = append(rtags, &tr) ··· 668 696 } 669 697 670 698 // add perms for this user to access the repo 671 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 699 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 672 700 if err != nil { 673 701 l.Error("adding repo permissions", "error", err.Error()) 674 702 writeError(w, err.Error(), http.StatusInternalServerError) ··· 687 715 } 688 716 689 717 func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 690 - l := h.l.With("handler", "RepoForkSync") 718 + l := h.l.With("handler", "RepoForkAheadBehind") 691 719 692 720 data := struct { 693 721 Did string `json:"did"` ··· 777 805 return 778 806 } 779 807 780 - sizes := make(map[string]int64) 781 - 782 808 ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 783 809 defer cancel() 784 810 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 - }) 811 + sizes, err := gr.AnalyzeLanguages(ctx) 812 812 if err != nil { 813 - l.Error("failed to recurse file tree", "error", err.Error()) 813 + l.Error("failed to analyze languages", "error", err.Error()) 814 814 writeError(w, err.Error(), http.StatusNoContent) 815 815 return 816 816 } ··· 818 818 resp := types.RepoLanguageResponse{Languages: sizes} 819 819 820 820 writeJSON(w, resp) 821 - return 822 - } 823 - 824 - func analyzeLanguage(node object.TreeEntry, content []byte) string { 825 - language, ok := enry.GetLanguageByExtension(node.Name) 826 - if ok { 827 - return language 828 - } 829 - 830 - language, ok = enry.GetLanguageByFilename(node.Name) 831 - if ok { 832 - return language 833 - } 834 - 835 - if len(content) == 0 { 836 - return enry.OtherLanguage 837 - } 838 - 839 - return enry.GetLanguage(node.Name, content) 840 821 } 841 822 842 823 func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { ··· 869 850 name = filepath.Base(source) 870 851 } 871 852 872 - branch := chi.URLParam(r, "branch") 853 + branch := chi.URLParam(r, "*") 873 854 branch, _ = url.PathUnescape(branch) 874 855 875 856 relativeRepoPath := filepath.Join(did, name) 876 857 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 877 858 878 - gr, err := git.PlainOpen(repoPath) 859 + gr, err := git.Open(repoPath, branch) 879 860 if err != nil { 880 861 log.Println(err) 881 862 notFound(w) 882 863 return 883 864 } 884 865 885 - err = gr.Sync(branch) 866 + err = gr.Sync() 886 867 if err != nil { 887 868 l.Error("error syncing repo fork", "error", err.Error()) 888 869 writeError(w, err.Error(), http.StatusInternalServerError) ··· 933 914 } 934 915 935 916 // add perms for this user to access the repo 936 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 917 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 937 918 if err != nil { 938 919 l.Error("adding repo permissions", "error", err.Error()) 939 920 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1187 1168 } 1188 1169 h.jc.AddDid(did) 1189 1170 1190 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1171 + if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1191 1172 l.Error("adding member", "error", err.Error()) 1192 1173 writeError(w, err.Error(), http.StatusInternalServerError) 1193 1174 return ··· 1225 1206 h.jc.AddDid(data.Did) 1226 1207 1227 1208 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1228 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1209 + if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1229 1210 l.Error("adding repo collaborator", "error", err.Error()) 1230 1211 writeError(w, err.Error(), http.StatusInternalServerError) 1231 1212 return ··· 1322 1303 } 1323 1304 h.jc.AddDid(data.Did) 1324 1305 1325 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1306 + if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1326 1307 l.Error("adding owner", "error", err.Error()) 1327 1308 writeError(w, err.Error(), http.StatusInternalServerError) 1328 1309 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 }
+1 -8
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "issue", 14 - "body", 15 - "createdAt" 16 - ], 12 + "required": ["issue", "body", "createdAt"], 17 13 "properties": { 18 14 "issue": { 19 15 "type": "string", ··· 22 18 "repo": { 23 19 "type": "string", 24 20 "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 21 }, 29 22 "owner": { 30 23 "type": "string",
+1 -10
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "owner", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 17 }, 27 18 "owner": { 28 19 "type": "string",
+207
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 + "engine", 153 + "clone", 154 + "raw" 155 + ], 156 + "properties": { 157 + "name": { 158 + "type": "string" 159 + }, 160 + "engine": { 161 + "type": "string" 162 + }, 163 + "clone": { 164 + "type": "ref", 165 + "ref": "#cloneOpts" 166 + }, 167 + "raw": { 168 + "type": "string" 169 + } 170 + } 171 + }, 172 + "cloneOpts": { 173 + "type": "object", 174 + "required": [ 175 + "skip", 176 + "depth", 177 + "submodules" 178 + ], 179 + "properties": { 180 + "skip": { 181 + "type": "boolean" 182 + }, 183 + "depth": { 184 + "type": "integer" 185 + }, 186 + "submodules": { 187 + "type": "boolean" 188 + } 189 + } 190 + }, 191 + "pair": { 192 + "type": "object", 193 + "required": [ 194 + "key", 195 + "value" 196 + ], 197 + "properties": { 198 + "key": { 199 + "type": "string" 200 + }, 201 + "value": { 202 + "type": "string" 203 + } 204 + } 205 + } 206 + } 207 + }
-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 -
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+3 -1
log/log.go
··· 9 9 // NewHandler sets up a new slog.Handler with the service name 10 10 // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 13 15 14 16 var attrs []slog.Attr 15 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+526
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/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 72 + [mod."github.com/containerd/errdefs"] 73 + version = "v1.0.0" 74 + hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" 75 + [mod."github.com/containerd/errdefs/pkg"] 76 + version = "v0.3.0" 77 + hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg=" 78 + [mod."github.com/containerd/log"] 79 + version = "v0.1.0" 80 + hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s=" 81 + [mod."github.com/cyphar/filepath-securejoin"] 82 + version = "v0.4.1" 83 + hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM=" 84 + [mod."github.com/davecgh/go-spew"] 85 + version = "v1.1.2-0.20180830191138-d8f796af33cc" 86 + hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 87 + [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 88 + version = "v4.4.0" 89 + hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 90 + [mod."github.com/dgraph-io/ristretto"] 91 + version = "v0.2.0" 92 + hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" 93 + [mod."github.com/dgryski/go-rendezvous"] 94 + version = "v0.0.0-20200823014737-9f7001d12a5f" 95 + hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI=" 96 + [mod."github.com/distribution/reference"] 97 + version = "v0.6.0" 98 + hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4=" 99 + [mod."github.com/dlclark/regexp2"] 100 + version = "v1.11.5" 101 + hash = "sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ=" 102 + [mod."github.com/docker/docker"] 103 + version = "v28.2.2+incompatible" 104 + hash = "sha256-5FnlTcygdxpHyFB0/7EsYocFhADUAjC/Dku0Xn4W8so=" 105 + [mod."github.com/docker/go-connections"] 106 + version = "v0.5.0" 107 + hash = "sha256-aGbMRrguh98DupIHgcpLkVUZpwycx1noQXbtTl5Sbms=" 108 + [mod."github.com/docker/go-units"] 109 + version = "v0.5.0" 110 + hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE=" 111 + [mod."github.com/dustin/go-humanize"] 112 + version = "v1.0.1" 113 + hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 114 + [mod."github.com/emirpasic/gods"] 115 + version = "v1.18.1" 116 + hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" 117 + [mod."github.com/felixge/httpsnoop"] 118 + version = "v1.0.4" 119 + hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 120 + [mod."github.com/fsnotify/fsnotify"] 121 + version = "v1.6.0" 122 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 123 + [mod."github.com/gliderlabs/ssh"] 124 + version = "v0.3.8" 125 + hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" 126 + [mod."github.com/go-chi/chi/v5"] 127 + version = "v5.2.0" 128 + hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A=" 129 + [mod."github.com/go-enry/go-enry/v2"] 130 + version = "v2.9.2" 131 + hash = "sha256-LkCSW+4+DkTok1JcOQR0rt3UKNKVn4KPaiDeatdQhCU=" 132 + [mod."github.com/go-enry/go-oniguruma"] 133 + version = "v1.2.1" 134 + hash = "sha256-DoCNyX75CuCgFnfSZs63VB4+HAIMDBgwcQglXXHRj/I=" 135 + [mod."github.com/go-git/gcfg"] 136 + version = "v1.5.1-0.20230307220236-3a3c6141e376" 137 + hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" 138 + [mod."github.com/go-git/go-billy/v5"] 139 + version = "v5.6.2" 140 + hash = "sha256-VgbxcLkHjiSyRIfKS7E9Sn8OynCrMGUDkwFz6K2TVL4=" 141 + [mod."github.com/go-git/go-git/v5"] 142 + version = "v5.17.0" 143 + hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 144 + replaced = "github.com/oppiliappan/go-git/v5" 145 + [mod."github.com/go-jose/go-jose/v3"] 146 + version = "v3.0.4" 147 + hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 148 + [mod."github.com/go-logr/logr"] 149 + version = "v1.4.3" 150 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 151 + [mod."github.com/go-logr/stdr"] 152 + version = "v1.2.2" 153 + hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 154 + [mod."github.com/go-redis/cache/v9"] 155 + version = "v9.0.0" 156 + hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 157 + [mod."github.com/go-test/deep"] 158 + version = "v1.1.1" 159 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 160 + [mod."github.com/goccy/go-json"] 161 + version = "v0.10.5" 162 + hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" 163 + [mod."github.com/gogo/protobuf"] 164 + version = "v1.3.2" 165 + hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 + [mod."github.com/golang-jwt/jwt/v5"] 167 + version = "v5.2.3" 168 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 169 + [mod."github.com/golang/groupcache"] 170 + version = "v0.0.0-20241129210726-2c02b8208cf8" 171 + hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 172 + [mod."github.com/golang/mock"] 173 + version = "v1.6.0" 174 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 178 + [mod."github.com/google/uuid"] 179 + version = "v1.6.0" 180 + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 181 + [mod."github.com/gorilla/css"] 182 + version = "v1.0.1" 183 + hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 187 + [mod."github.com/gorilla/securecookie"] 188 + version = "v1.1.2" 189 + hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" 190 + [mod."github.com/gorilla/sessions"] 191 + version = "v1.4.0" 192 + hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 193 + [mod."github.com/gorilla/websocket"] 194 + version = "v1.5.4-0.20250319132907-e064f32e3674" 195 + hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to=" 196 + [mod."github.com/hashicorp/errwrap"] 197 + version = "v1.1.0" 198 + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" 199 + [mod."github.com/hashicorp/go-cleanhttp"] 200 + version = "v0.5.2" 201 + hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 202 + [mod."github.com/hashicorp/go-multierror"] 203 + version = "v1.1.1" 204 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 205 + [mod."github.com/hashicorp/go-retryablehttp"] 206 + version = "v0.7.8" 207 + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 208 + [mod."github.com/hashicorp/go-secure-stdlib/parseutil"] 209 + version = "v0.2.0" 210 + hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8=" 211 + [mod."github.com/hashicorp/go-secure-stdlib/strutil"] 212 + version = "v0.1.2" 213 + hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A=" 214 + [mod."github.com/hashicorp/go-sockaddr"] 215 + version = "v1.0.7" 216 + hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 217 + [mod."github.com/hashicorp/golang-lru"] 218 + version = "v1.0.2" 219 + hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 220 + [mod."github.com/hashicorp/golang-lru/v2"] 221 + version = "v2.0.7" 222 + hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 223 + [mod."github.com/hashicorp/hcl"] 224 + version = "v1.0.1-vault-7" 225 + hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM=" 226 + [mod."github.com/hexops/gotextdiff"] 227 + version = "v1.0.3" 228 + hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0=" 229 + [mod."github.com/hiddeco/sshsig"] 230 + version = "v0.2.0" 231 + hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" 232 + [mod."github.com/hpcloud/tail"] 233 + version = "v1.0.0" 234 + hash = "sha256-7ByBr/RcOwIsGPCiCUpfNwUSvU18QAY+HMnCJr8uU1w=" 235 + [mod."github.com/ipfs/bbloom"] 236 + version = "v0.0.4" 237 + hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 238 + [mod."github.com/ipfs/boxo"] 239 + version = "v0.33.0" 240 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 241 + [mod."github.com/ipfs/go-block-format"] 242 + version = "v0.2.2" 243 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 244 + [mod."github.com/ipfs/go-cid"] 245 + version = "v0.5.0" 246 + hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" 247 + [mod."github.com/ipfs/go-datastore"] 248 + version = "v0.8.2" 249 + hash = "sha256-9Q7+bi04srAE3AcXzWSGs/HP6DWnE1Edtx3NnjMQi8U=" 250 + [mod."github.com/ipfs/go-ipfs-blockstore"] 251 + version = "v1.3.1" 252 + hash = "sha256-NFlKr8bdJcM5FLlkc51sKt4AnMMlHS4wbdKiiaoDaqg=" 253 + [mod."github.com/ipfs/go-ipfs-ds-help"] 254 + version = "v1.1.1" 255 + hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 256 + [mod."github.com/ipfs/go-ipld-cbor"] 257 + version = "v0.2.1" 258 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 259 + [mod."github.com/ipfs/go-ipld-format"] 260 + version = "v0.6.2" 261 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 262 + [mod."github.com/ipfs/go-log"] 263 + version = "v1.0.5" 264 + hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" 265 + [mod."github.com/ipfs/go-log/v2"] 266 + version = "v2.6.0" 267 + hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk=" 268 + [mod."github.com/ipfs/go-metrics-interface"] 269 + version = "v0.3.0" 270 + hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 271 + [mod."github.com/kevinburke/ssh_config"] 272 + version = "v1.2.0" 273 + hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" 274 + [mod."github.com/klauspost/compress"] 275 + version = "v1.18.0" 276 + hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 277 + [mod."github.com/klauspost/cpuid/v2"] 278 + version = "v2.3.0" 279 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 280 + [mod."github.com/lestrrat-go/blackmagic"] 281 + version = "v1.0.4" 282 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 283 + [mod."github.com/lestrrat-go/httpcc"] 284 + version = "v1.0.1" 285 + hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 286 + [mod."github.com/lestrrat-go/httprc"] 287 + version = "v1.0.6" 288 + hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 289 + [mod."github.com/lestrrat-go/iter"] 290 + version = "v1.0.2" 291 + hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 292 + [mod."github.com/lestrrat-go/jwx/v2"] 293 + version = "v2.1.6" 294 + hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 295 + [mod."github.com/lestrrat-go/option"] 296 + version = "v1.0.1" 297 + hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 298 + [mod."github.com/mattn/go-isatty"] 299 + version = "v0.0.20" 300 + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 301 + [mod."github.com/mattn/go-sqlite3"] 302 + version = "v1.14.24" 303 + hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" 304 + [mod."github.com/microcosm-cc/bluemonday"] 305 + version = "v1.0.27" 306 + hash = "sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=" 307 + [mod."github.com/minio/sha256-simd"] 308 + version = "v1.0.1" 309 + hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 310 + [mod."github.com/mitchellh/mapstructure"] 311 + version = "v1.5.0" 312 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 313 + [mod."github.com/moby/docker-image-spec"] 314 + version = "v1.3.1" 315 + hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" 316 + [mod."github.com/moby/sys/atomicwriter"] 317 + version = "v0.1.0" 318 + hash = "sha256-i46GNrsICnJ0AYkN+ocbVZ2GNTQVEsrVX5WcjKzjtBM=" 319 + [mod."github.com/moby/term"] 320 + version = "v0.5.2" 321 + hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 322 + [mod."github.com/morikuni/aec"] 323 + version = "v1.0.0" 324 + hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 325 + [mod."github.com/mr-tron/base58"] 326 + version = "v1.2.0" 327 + hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 328 + [mod."github.com/multiformats/go-base32"] 329 + version = "v0.1.0" 330 + hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" 331 + [mod."github.com/multiformats/go-base36"] 332 + version = "v0.2.0" 333 + hash = "sha256-GKNnAGA0Lb39BDGYBm1ieKdXmho8Pu7ouyfVPXvV0PE=" 334 + [mod."github.com/multiformats/go-multibase"] 335 + version = "v0.2.0" 336 + hash = "sha256-w+hp6u5bWyd34qe0CX+bq487ADqq6SgRR/JuqRB578s=" 337 + [mod."github.com/multiformats/go-multihash"] 338 + version = "v0.2.3" 339 + hash = "sha256-zqIIE5jMFzm+qhUrouSF+WdXGeHUEYIQvVnKWWU6mRs=" 340 + [mod."github.com/multiformats/go-varint"] 341 + version = "v0.0.7" 342 + hash = "sha256-To3Uuv7uSUJEr5OTwxE1LEIpA62xY3M/KKMNlscHmlA=" 343 + [mod."github.com/munnerz/goautoneg"] 344 + version = "v0.0.0-20191010083416-a7dc8b61c822" 345 + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 346 + [mod."github.com/onsi/gomega"] 347 + version = "v1.37.0" 348 + hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o=" 349 + [mod."github.com/openbao/openbao/api/v2"] 350 + version = "v2.3.0" 351 + hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM=" 352 + [mod."github.com/opencontainers/go-digest"] 353 + version = "v1.0.0" 354 + hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" 355 + [mod."github.com/opencontainers/image-spec"] 356 + version = "v1.1.1" 357 + hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 358 + [mod."github.com/opentracing/opentracing-go"] 359 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 360 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 361 + [mod."github.com/pjbgf/sha1cd"] 362 + version = "v0.3.2" 363 + hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" 364 + [mod."github.com/pkg/errors"] 365 + version = "v0.9.1" 366 + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" 367 + [mod."github.com/pmezard/go-difflib"] 368 + version = "v1.0.1-0.20181226105442-5d4384ee4fb2" 369 + hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=" 370 + [mod."github.com/polydawn/refmt"] 371 + version = "v0.89.1-0.20221221234430-40501e09de1f" 372 + hash = "sha256-wBdFROClTHNPYU4IjeKbBXaG7F6j5hZe15gMxiqKvi4=" 373 + [mod."github.com/posthog/posthog-go"] 374 + version = "v1.5.5" 375 + hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8=" 376 + [mod."github.com/prometheus/client_golang"] 377 + version = "v1.22.0" 378 + hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ=" 379 + [mod."github.com/prometheus/client_model"] 380 + version = "v0.6.2" 381 + hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 382 + [mod."github.com/prometheus/common"] 383 + version = "v0.64.0" 384 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 385 + [mod."github.com/prometheus/procfs"] 386 + version = "v0.16.1" 387 + hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 388 + [mod."github.com/redis/go-redis/v9"] 389 + version = "v9.7.3" 390 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 391 + [mod."github.com/resend/resend-go/v2"] 392 + version = "v2.15.0" 393 + hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 394 + [mod."github.com/ryanuber/go-glob"] 395 + version = "v1.0.0" 396 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 397 + [mod."github.com/segmentio/asm"] 398 + version = "v1.2.0" 399 + hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 400 + [mod."github.com/sergi/go-diff"] 401 + version = "v1.1.0" 402 + hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" 403 + replaced = "github.com/sergi/go-diff" 404 + [mod."github.com/sethvargo/go-envconfig"] 405 + version = "v1.1.0" 406 + hash = "sha256-WelRHfyZG9hrA4fbQcfBawb2ZXBQNT1ourEYHzQdZ4w=" 407 + [mod."github.com/spaolacci/murmur3"] 408 + version = "v1.1.0" 409 + hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 410 + [mod."github.com/stretchr/testify"] 411 + version = "v1.10.0" 412 + hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 413 + [mod."github.com/urfave/cli/v3"] 414 + version = "v3.3.3" 415 + hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 416 + [mod."github.com/vmihailenco/go-tinylfu"] 417 + version = "v0.2.2" 418 + hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" 419 + [mod."github.com/vmihailenco/msgpack/v5"] 420 + version = "v5.4.1" 421 + hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk=" 422 + [mod."github.com/vmihailenco/tagparser/v2"] 423 + version = "v2.0.0" 424 + hash = "sha256-M9QyaKhSmmYwsJk7gkjtqu9PuiqZHSmTkous8VWkWY0=" 425 + [mod."github.com/whyrusleeping/cbor-gen"] 426 + version = "v0.3.1" 427 + hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/yuin/goldmark"] 429 + version = "v1.4.15" 430 + hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 431 + [mod."github.com/yuin/goldmark-highlighting/v2"] 432 + version = "v2.0.0-20230729083705-37449abec8cc" 433 + hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 434 + [mod."gitlab.com/yawning/secp256k1-voi"] 435 + version = "v0.0.0-20230925100816-f2616030848b" 436 + hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 437 + [mod."gitlab.com/yawning/tuplehash"] 438 + version = "v0.0.0-20230713102510-df83abbf9a02" 439 + hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 440 + [mod."go.opentelemetry.io/auto/sdk"] 441 + version = "v1.1.0" 442 + hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 443 + [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 444 + version = "v0.62.0" 445 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 446 + [mod."go.opentelemetry.io/otel"] 447 + version = "v1.37.0" 448 + hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 449 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 450 + version = "v1.33.0" 451 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 452 + [mod."go.opentelemetry.io/otel/metric"] 453 + version = "v1.37.0" 454 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 455 + [mod."go.opentelemetry.io/otel/trace"] 456 + version = "v1.37.0" 457 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 458 + [mod."go.opentelemetry.io/proto/otlp"] 459 + version = "v1.6.0" 460 + hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" 461 + [mod."go.uber.org/atomic"] 462 + version = "v1.11.0" 463 + hash = "sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY=" 464 + [mod."go.uber.org/multierr"] 465 + version = "v1.11.0" 466 + hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" 467 + [mod."go.uber.org/zap"] 468 + version = "v1.27.0" 469 + hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 470 + [mod."golang.org/x/crypto"] 471 + version = "v0.40.0" 472 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 473 + [mod."golang.org/x/exp"] 474 + version = "v0.0.0-20250620022241-b7579e27df2b" 475 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 476 + [mod."golang.org/x/net"] 477 + version = "v0.42.0" 478 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 479 + [mod."golang.org/x/sync"] 480 + version = "v0.16.0" 481 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 482 + [mod."golang.org/x/sys"] 483 + version = "v0.34.0" 484 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 485 + [mod."golang.org/x/text"] 486 + version = "v0.27.0" 487 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 488 + [mod."golang.org/x/time"] 489 + version = "v0.12.0" 490 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 491 + [mod."golang.org/x/xerrors"] 492 + version = "v0.0.0-20240903120638-7835f813f4da" 493 + hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 494 + [mod."google.golang.org/genproto/googleapis/api"] 495 + version = "v0.0.0-20250603155806-513f23925822" 496 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 497 + [mod."google.golang.org/genproto/googleapis/rpc"] 498 + version = "v0.0.0-20250603155806-513f23925822" 499 + hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 500 + [mod."google.golang.org/grpc"] 501 + version = "v1.73.0" 502 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 503 + [mod."google.golang.org/protobuf"] 504 + version = "v1.36.6" 505 + hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" 506 + [mod."gopkg.in/fsnotify.v1"] 507 + version = "v1.4.7" 508 + hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8=" 509 + [mod."gopkg.in/tomb.v1"] 510 + version = "v1.0.0-20141024135613-dd632973f1e7" 511 + hash = "sha256-W/4wBAvuaBFHhowB67SZZfXCRDp5tzbYG4vo81TAFdM=" 512 + [mod."gopkg.in/warnings.v0"] 513 + version = "v0.1.2" 514 + hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8=" 515 + [mod."gopkg.in/yaml.v3"] 516 + version = "v3.0.1" 517 + hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" 518 + [mod."gotest.tools/v3"] 519 + version = "v3.5.2" 520 + hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE=" 521 + [mod."lukechampine.com/blake3"] 522 + version = "v1.4.1" 523 + hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 524 + [mod."tangled.sh/icyphox.sh/atproto-oauth"] 525 + version = "v0.0.0-20250724194903-28e660378cb1" 526 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+54 -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 + }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 24 43 }; 25 44 }; 26 - }; 27 45 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 46 + config = mkIf cfg.enable { 47 + systemd.services.tangled-appview = { 48 + description = "tangled appview service"; 49 + wantedBy = ["multi-user.target"]; 32 50 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 - }; 51 + serviceConfig = { 52 + ListenStream = "0.0.0.0:${toString cfg.port}"; 53 + ExecStart = "${cfg.package}/bin/appview"; 54 + Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 56 + }; 38 57 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 58 + environment = { 59 + TANGLED_DB_PATH = "appview.db"; 60 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 + }; 42 62 }; 43 63 }; 44 - }; 45 - } 64 + }
+60 -19
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 = '' 103 - mkdir -p "${cfg.repo.scanPath}" 104 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 105 - 106 - mkdir -p "${cfg.stateDir}/.config/git" 107 - cat > "${cfg.stateDir}/.config/git/config" << EOF 108 - [user] 109 - name = Git User 110 - email = git@example.com 111 - EOF 112 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 113 - ''; 114 - 115 129 users.users.${cfg.gitUser} = { 116 130 isSystemUser = true; 117 131 useDefaultShell = true; ··· 135 149 mode = "0555"; 136 150 text = '' 137 151 #!${pkgs.stdenv.shell} 138 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 152 + ${cfg.package}/bin/knot keys \ 139 153 -output authorized-keys \ 140 154 -internal-api "http://${cfg.server.internalListenAddr}" \ 141 155 -git-dir "${cfg.repo.scanPath}" \ ··· 147 161 description = "knot service"; 148 162 after = ["network.target" "sshd.service"]; 149 163 wantedBy = ["multi-user.target"]; 164 + enableStrictShellChecks = true; 165 + 166 + preStart = let 167 + setMotd = 168 + if cfg.motdFile != null && cfg.motd != null 169 + then throw "motdFile and motd cannot be both set" 170 + else '' 171 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 172 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 173 + ''; 174 + in '' 175 + mkdir -p "${cfg.repo.scanPath}" 176 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 177 + 178 + mkdir -p "${cfg.stateDir}/.config/git" 179 + cat > "${cfg.stateDir}/.config/git/config" << EOF 180 + [user] 181 + name = Git User 182 + email = git@example.com 183 + [receive] 184 + advertisePushOptions = true 185 + EOF 186 + ${setMotd} 187 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 188 + ''; 189 + 150 190 serviceConfig = { 151 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 152 193 WorkingDirectory = cfg.stateDir; 153 194 Environment = [ 154 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" ··· 160 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 161 202 ]; 162 203 EnvironmentFile = cfg.server.secretFile; 163 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 204 + ExecStart = "${cfg.package}/bin/knot server"; 164 205 Restart = "always"; 165 206 }; 166 207 };
+31 -6
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 = { ··· 51 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 52 55 description = "DID of owner (required)"; 53 56 }; 57 + 58 + secrets = { 59 + provider = mkOption { 60 + type = types.str; 61 + default = "sqlite"; 62 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 63 + }; 64 + 65 + openbao = { 66 + proxyAddr = mkOption { 67 + type = types.str; 68 + default = "http://127.0.0.1:8200"; 69 + }; 70 + mount = mkOption { 71 + type = types.str; 72 + default = "spindle"; 73 + }; 74 + }; 75 + }; 54 76 }; 55 77 56 78 pipelines = { ··· 60 82 description = "Nixery instance to use"; 61 83 }; 62 84 63 - stepTimeout = mkOption { 85 + workflowTimeout = mkOption { 64 86 type = types.str; 65 87 default = "5m"; 66 88 description = "Timeout for each step of a pipeline"; ··· 86 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 87 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 88 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 89 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 - "SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}" 111 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 114 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 91 116 ]; 92 - ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 117 + ExecStart = "${cfg.package}/bin/spindle"; 93 118 Restart = "always"; 94 119 }; 95 120 };
+29
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 19 + mkdir -p $out/{fonts,icons} && cd $out 20 + cp -f ${htmx-src} htmx.min.js 21 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 + cp -rf ${lucide-src}/*.svg icons/ 23 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 + # for whatever reason (produces broken css), so we are doing this instead 28 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 29 + ''
+10 -25
nix/pkgs/appview.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 2 + buildGoApplication, 3 + modules, 4 + appview-static-files, 10 5 sqlite-lib, 11 - goModHash, 12 - gitignoreSource, 6 + src, 13 7 }: 14 - buildGoModule { 15 - inherit stdenv; 16 - 8 + buildGoApplication { 17 9 pname = "appview"; 18 10 version = "0.1.0"; 19 - src = gitignoreSource ../..; 11 + inherit src modules; 20 12 21 13 postUnpack = '' 22 14 pushd source 23 - mkdir -p appview/pages/static/{fonts,icons} 24 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 25 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 26 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 27 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 28 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 29 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 30 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 31 17 popd 32 18 ''; 33 19 34 20 doCheck = false; 35 21 subPackages = ["cmd/appview"]; 36 - vendorHash = goModHash; 37 22 38 - tags = "libsqlite3"; 23 + tags = ["libsqlite3"]; 39 24 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 40 25 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 41 - env.CGO_ENABLED = 1; 26 + CGO_ENABLED = 1; 42 27 }
+12 -8
nix/pkgs/genjwks.nix
··· 1 1 { 2 - buildGoModule, 3 - goModHash, 4 - gitignoreSource, 2 + buildGoApplication, 3 + modules, 5 4 }: 6 - buildGoModule { 5 + buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - src = gitignoreSource ../..; 10 - subPackages = ["cmd/genjwks"]; 11 - vendorHash = goModHash; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 15 + inherit modules; 12 16 doCheck = false; 13 - env.CGO_ENABLED = 0; 17 + CGO_ENABLED = 0; 14 18 }
+7 -9
nix/pkgs/knot-unwrapped.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 - gitignoreSource, 5 + src, 7 6 }: 8 - buildGoModule { 7 + buildGoApplication { 9 8 pname = "knot"; 10 9 version = "0.1.0"; 11 - src = gitignoreSource ../..; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13 15 14 subPackages = ["cmd/knot"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 15 + tags = ["libsqlite3"]; 18 16 19 17 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 18 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 19 + CGO_ENABLED = 1; 22 20 }
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+7 -9
nix/pkgs/spindle.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 - gitignoreSource, 5 + src, 7 6 }: 8 - buildGoModule { 7 + buildGoApplication { 9 8 pname = "spindle"; 10 9 version = "0.1.0"; 11 - src = gitignoreSource ../..; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13 15 14 subPackages = ["cmd/spindle"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 15 + tags = ["libsqlite3"]; 18 16 19 17 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 18 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 19 + CGO_ENABLED = 1; 22 20 }
+120 -63
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 4 + hostSystem, 3 5 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - self.nixosModules.spindle 10 - ({ 11 - config, 12 - pkgs, 13 - ... 14 - }: { 15 - virtualisation = { 16 - memorySize = 2048; 17 - diskSize = 10 * 1024; 18 - cores = 2; 19 - forwardPorts = [ 20 - # ssh 21 - { 22 - from = "host"; 23 - host.port = 2222; 24 - guest.port = 22; 25 - } 26 - # knot 27 - { 28 - from = "host"; 29 - host.port = 6000; 30 - guest.port = 6000; 31 - } 32 - # spindle 33 - { 34 - from = "host"; 35 - host.port = 6555; 36 - guest.port = 6555; 37 - } 38 - ]; 39 - }; 40 - services.getty.autologinUser = "root"; 41 - environment.systemPackages = with pkgs; [curl vim git]; 42 - systemd.tmpfiles.rules = let 43 - u = config.services.tangled-knot.gitUser; 44 - g = config.services.tangled-knot.gitUser; 45 - in [ 46 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 47 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440" 48 - ]; 49 - services.tangled-knot = { 50 - enable = true; 51 - server = { 52 - secretFile = "/var/lib/knot/secret"; 53 - hostname = "localhost:6000"; 54 - listenAddr = "0.0.0.0:6000"; 6 + }: let 7 + envVar = name: let 8 + var = builtins.getEnv name; 9 + in 10 + if var == "" 11 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 + else var; 13 + in 14 + nixpkgs.lib.nixosSystem { 15 + inherit system; 16 + modules = [ 17 + self.nixosModules.knot 18 + self.nixosModules.spindle 19 + ({ 20 + lib, 21 + config, 22 + pkgs, 23 + ... 24 + }: { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 29 + memorySize = 2048; 30 + diskSize = 10 * 1024; 31 + cores = 2; 32 + forwardPorts = [ 33 + # ssh 34 + { 35 + from = "host"; 36 + host.port = 2222; 37 + guest.port = 22; 38 + } 39 + # knot 40 + { 41 + from = "host"; 42 + host.port = 6000; 43 + guest.port = 6000; 44 + } 45 + # spindle 46 + { 47 + from = "host"; 48 + host.port = 6555; 49 + guest.port = 6555; 50 + } 51 + ]; 52 + sharedDirectories = { 53 + # We can't use the 9p mounts directly for most of these 54 + # as SQLite is incompatible with them. So instead we 55 + # mount the shared directories to a different location 56 + # and copy the contents around on service start/stop. 57 + knotData = { 58 + source = "$TANGLED_VM_DATA_DIR/knot"; 59 + target = "/mnt/knot-data"; 60 + }; 61 + spindleData = { 62 + source = "$TANGLED_VM_DATA_DIR/spindle"; 63 + target = "/mnt/spindle-data"; 64 + }; 65 + spindleLogs = { 66 + source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 67 + target = "/var/log/spindle"; 68 + }; 69 + }; 70 + }; 71 + # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 + networking.firewall.enable = false; 73 + services.getty.autologinUser = "root"; 74 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 75 + services.tangled-knot = { 76 + enable = true; 77 + motd = "Welcome to the development knot!\n"; 78 + server = { 79 + secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 80 + hostname = "localhost:6000"; 81 + listenAddr = "0.0.0.0:6000"; 82 + }; 83 + }; 84 + services.tangled-spindle = { 85 + enable = true; 86 + server = { 87 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 88 + hostname = "localhost:6555"; 89 + listenAddr = "0.0.0.0:6555"; 90 + dev = true; 91 + secrets = { 92 + provider = "sqlite"; 93 + }; 94 + }; 95 + }; 96 + users = { 97 + # So we don't have to deal with permission clashing between 98 + # blank disk VMs and existing state 99 + users.${config.services.tangled-knot.gitUser}.uid = 666; 100 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 101 + 102 + # TODO: separate spindle user 55 103 }; 56 - }; 57 - services.tangled-spindle = { 58 - enable = true; 59 - server = { 60 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 61 - hostname = "localhost:6555"; 62 - listenAddr = "0.0.0.0:6555"; 63 - dev = true; 104 + systemd.services = let 105 + mkDataSyncScripts = source: target: { 106 + enableStrictShellChecks = true; 107 + 108 + preStart = lib.mkBefore '' 109 + mkdir -p ${target} 110 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 111 + ''; 112 + 113 + postStop = lib.mkAfter '' 114 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 115 + ''; 116 + 117 + serviceConfig.PermissionsStartOnly = true; 118 + }; 119 + in { 120 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 121 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 64 122 }; 65 - }; 66 - }) 67 - ]; 68 - } 123 + }) 124 + ]; 125 + }
+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 {
+5 -1
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 ··· 39 43 return nil, err 40 44 } 41 45 42 - db, err := sql.Open("sqlite3", path) 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 43 47 if err != nil { 44 48 return nil, err 45 49 }
+1 -1
rbac/rbac_test.go
··· 14 14 ) 15 15 16 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 18 assert.NoError(t, err) 19 19 20 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+27 -10
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 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 + } 21 + 22 + func (s Server) Did() syntax.DID { 23 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 24 + } 25 + 26 + type Secrets struct { 27 + Provider string `env:"PROVIDER, default=sqlite"` 28 + OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` 16 29 } 17 30 18 - type Pipelines struct { 31 + type OpenBaoConfig struct { 32 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 33 + Mount string `env:"MOUNT, default=spindle"` 34 + } 35 + 36 + type NixeryPipelines struct { 19 37 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 20 38 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 21 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 22 39 } 23 40 24 41 type Config struct { 25 - Server Server `env:",prefix=SPINDLE_SERVER_"` 26 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 42 + Server Server `env:",prefix=SPINDLE_SERVER_"` 43 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 27 44 } 28 45 29 46 func Load(ctx context.Context) (*Config, error) {
+29 -10
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 27 + 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 18 31 19 32 _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 - 29 33 create table if not exists _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null ··· 43 47 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 48 45 49 unique(owner, name) 50 + ); 51 + 52 + create table if not exists spindle_members ( 53 + -- identifiers for the record 54 + id integer primary key autoincrement, 55 + did text not null, 56 + rkey text not null, 57 + 58 + -- data 59 + instance text not null, 60 + subject text not null, 61 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 62 + 63 + -- constraints 64 + unique (did, instance, subject) 46 65 ); 47 66 48 67 -- status event for a single workflow
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
-21
spindle/engine/ansi_stripper.go
··· 1 - package engine 2 - 3 - import ( 4 - "io" 5 - 6 - "regexp" 7 - ) 8 - 9 - // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 - const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 - 12 - var re = regexp.MustCompile(ansi) 13 - 14 - type ansiStrippingWriter struct { 15 - underlying io.Writer 16 - } 17 - 18 - func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 - clean := re.ReplaceAll(p, []byte{}) 20 - return w.underlying.Write(clean) 21 - }
+77 -401
spindle/engine/engine.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 7 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 8 14 - "github.com/docker/docker/api/types/container" 15 - "github.com/docker/docker/api/types/image" 16 - "github.com/docker/docker/api/types/mount" 17 - "github.com/docker/docker/api/types/network" 18 - "github.com/docker/docker/api/types/volume" 19 - "github.com/docker/docker/client" 20 - "github.com/docker/docker/pkg/stdcopy" 21 - "tangled.sh/tangled.sh/core/log" 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "golang.org/x/sync/errgroup" 22 11 "tangled.sh/tangled.sh/core/notifier" 23 12 "tangled.sh/tangled.sh/core/spindle/config" 24 13 "tangled.sh/tangled.sh/core/spindle/db" 25 14 "tangled.sh/tangled.sh/core/spindle/models" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 16 ) 27 17 28 - const ( 29 - workspaceDir = "/tangled/workspace" 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 30 21 ) 31 22 32 - type cleanupFunc func(context.Context) error 33 - 34 - type Engine struct { 35 - docker client.APIClient 36 - l *slog.Logger 37 - db *db.DB 38 - n *notifier.Notifier 39 - cfg *config.Config 40 - 41 - cleanupMu sync.Mutex 42 - cleanup map[string][]cleanupFunc 43 - } 23 + func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 24 + l.Info("starting all workflows in parallel", "pipeline", pipelineId) 44 25 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 46 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 - if err != nil { 48 - return nil, err 49 - } 50 - 51 - l := log.FromContext(ctx).With("component", "spindle") 52 - 53 - e := &Engine{ 54 - docker: dcli, 55 - l: l, 56 - db: db, 57 - n: n, 58 - cfg: cfg, 26 + // extract secrets 27 + var allSecrets []secrets.UnlockedSecret 28 + if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 30 + allSecrets = res 31 + } 59 32 } 60 33 61 - e.cleanup = make(map[string][]cleanupFunc) 62 - 63 - return e, nil 64 - } 65 - 66 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 - 69 - wg := sync.WaitGroup{} 70 - for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 74 - wid := models.WorkflowId{ 75 - PipelineId: pipelineId, 76 - Name: w.Name, 77 - } 78 - 79 - err := e.db.StatusRunning(wid, e.n) 80 - if err != nil { 81 - return err 82 - } 34 + eg, ctx := errgroup.WithContext(ctx) 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 83 38 84 - err = e.SetupWorkflow(ctx, wid) 85 - if err != nil { 86 - e.l.Error("setting up worklow", "wid", wid, "err", err) 87 - return err 88 - } 89 - defer e.DestroyWorkflow(ctx, wid) 90 - 91 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 92 - if err != nil { 93 - e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error()) 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 94 45 95 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 46 + err := db.StatusRunning(wid, n) 96 47 if err != nil { 97 48 return err 98 49 } 99 50 100 - return fmt.Errorf("pulling image: %w", err) 101 - } 102 - defer reader.Close() 103 - io.Copy(os.Stdout, reader) 51 + err = eng.SetupWorkflow(ctx, wid, &w) 52 + if err != nil { 53 + // TODO(winter): Should this always set StatusFailed? 54 + // In the original, we only do in a subset of cases. 55 + l.Error("setting up worklow", "wid", wid, "err", err) 104 56 105 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 106 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 107 - if err != nil { 108 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 109 - workflowTimeout = 5 * time.Minute 110 - } 111 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 112 - ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 - defer cancel() 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 60 + } 114 61 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 116 - if err != nil { 117 - if errors.Is(err, ErrTimedOut) { 118 - dbErr := e.db.StatusTimeout(wid, e.n) 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 119 63 if dbErr != nil { 120 64 return dbErr 121 65 } 122 - } else { 123 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 124 - if dbErr != nil { 125 - return dbErr 126 - } 66 + return err 127 67 } 128 - 129 - return fmt.Errorf("starting steps image: %w", err) 130 - } 131 - 132 - err = e.db.StatusSuccess(wid, e.n) 133 - if err != nil { 134 - return err 135 - } 136 - 137 - return nil 138 - }() 139 - } 140 - 141 - wg.Wait() 142 - } 143 - 144 - // SetupWorkflow sets up a new network for the workflow and volumes for 145 - // the workspace and Nix store. These are persisted across steps and are 146 - // destroyed at the end of the workflow. 147 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 148 - e.l.Info("setting up workflow", "workflow", wid) 149 - 150 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 151 - Name: workspaceVolume(wid), 152 - Driver: "local", 153 - }) 154 - if err != nil { 155 - return err 156 - } 157 - e.registerCleanup(wid, func(ctx context.Context) error { 158 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 159 - }) 160 - 161 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 162 - Name: nixVolume(wid), 163 - Driver: "local", 164 - }) 165 - if err != nil { 166 - return err 167 - } 168 - e.registerCleanup(wid, func(ctx context.Context) error { 169 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 170 - }) 171 - 172 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 173 - Driver: "bridge", 174 - }) 175 - if err != nil { 176 - return err 177 - } 178 - e.registerCleanup(wid, func(ctx context.Context) error { 179 - return e.docker.NetworkRemove(ctx, networkName(wid)) 180 - }) 181 - 182 - return nil 183 - } 184 - 185 - // StartSteps starts all steps sequentially with the same base image. 186 - // ONLY marks pipeline as failed if container's exit code is non-zero. 187 - // All other errors are bubbled up. 188 - // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 190 - 191 - for stepIdx, step := range steps { 192 - select { 193 - case <-ctx.Done(): 194 - return ctx.Err() 195 - default: 196 - } 197 - 198 - envs := ConstructEnvs(step.Environment) 199 - envs.AddEnv("HOME", workspaceDir) 200 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 - 202 - hostConfig := hostConfig(wid) 203 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 205 - Cmd: []string{"bash", "-c", step.Command}, 206 - WorkingDir: workspaceDir, 207 - Tty: false, 208 - Hostname: "spindle", 209 - Env: envs.Slice(), 210 - }, hostConfig, nil, nil, "") 211 - defer e.DestroyStep(ctx, resp.ID) 212 - if err != nil { 213 - return fmt.Errorf("creating container: %w", err) 214 - } 215 - 216 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 217 - if err != nil { 218 - return fmt.Errorf("connecting network: %w", err) 219 - } 220 - 221 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 222 - if err != nil { 223 - return err 224 - } 225 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 226 - 227 - // start tailing logs in background 228 - tailDone := make(chan error, 1) 229 - go func() { 230 - tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step) 231 - }() 232 - 233 - // wait for container completion or timeout 234 - waitDone := make(chan struct{}) 235 - var state *container.State 236 - var waitErr error 237 - 238 - go func() { 239 - defer close(waitDone) 240 - state, waitErr = e.WaitStep(ctx, resp.ID) 241 - }() 242 - 243 - select { 244 - case <-waitDone: 245 - 246 - // wait for tailing to complete 247 - <-tailDone 248 - 249 - case <-ctx.Done(): 250 - e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name) 251 - err = e.DestroyStep(context.Background(), resp.ID) 252 - if err != nil { 253 - e.l.Error("failed to destroy step", "container", resp.ID, "error", err) 254 - } 68 + defer eng.DestroyWorkflow(ctx, wid) 255 69 256 - // wait for both goroutines to finish 257 - <-waitDone 258 - <-tailDone 259 - 260 - return ErrTimedOut 261 - } 70 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 + if err != nil { 72 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 + wfLogger = nil 74 + } else { 75 + defer wfLogger.Close() 76 + } 262 77 263 - select { 264 - case <-ctx.Done(): 265 - return ctx.Err() 266 - default: 267 - } 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 268 80 269 - if waitErr != nil { 270 - return waitErr 271 - } 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 272 86 273 - err = e.DestroyStep(ctx, resp.ID) 274 - if err != nil { 275 - return err 276 - } 87 + err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 + if err != nil { 89 + if errors.Is(err, ErrTimedOut) { 90 + dbErr := db.StatusTimeout(wid, n) 91 + if dbErr != nil { 92 + return dbErr 93 + } 94 + } else { 95 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 + if dbErr != nil { 97 + return dbErr 98 + } 99 + } 277 100 278 - if state.ExitCode != 0 { 279 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 280 - if state.OOMKilled { 281 - return ErrOOMKilled 282 - } 283 - return ErrWorkflowFailed 284 - } 285 - } 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 286 104 287 - return nil 288 - } 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 289 109 290 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 291 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 292 - select { 293 - case err := <-errCh: 294 - if err != nil { 295 - return nil, err 110 + return nil 111 + }) 296 112 } 297 - case <-wait: 298 113 } 299 114 300 - e.l.Info("waited for container", "name", containerID) 301 - 302 - info, err := e.docker.ContainerInspect(ctx, containerID) 303 - if err != nil { 304 - return nil, err 305 - } 306 - 307 - return info.State, nil 308 - } 309 - 310 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 311 - wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid) 312 - if err != nil { 313 - e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 314 - return err 115 + if err := eg.Wait(); err != nil { 116 + l.Error("failed to run one or more workflows", "err", err) 117 + } else { 118 + l.Error("successfully ran full pipeline") 315 119 } 316 - defer wfLogger.Close() 317 - 318 - ctl := wfLogger.ControlWriter(stepIdx, step) 319 - ctl.Write([]byte(step.Name)) 320 - 321 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 322 - Follow: true, 323 - ShowStdout: true, 324 - ShowStderr: true, 325 - Details: false, 326 - Timestamps: false, 327 - }) 328 - if err != nil { 329 - return err 330 - } 331 - 332 - _, err = stdcopy.StdCopy( 333 - wfLogger.DataWriter("stdout"), 334 - wfLogger.DataWriter("stderr"), 335 - logs, 336 - ) 337 - if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 338 - return fmt.Errorf("failed to copy logs: %w", err) 339 - } 340 - 341 - return nil 342 - } 343 - 344 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 345 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 346 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 347 - return err 348 - } 349 - 350 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 351 - RemoveVolumes: true, 352 - RemoveLinks: false, 353 - Force: false, 354 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 355 - return err 356 - } 357 - 358 - return nil 359 - } 360 - 361 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 362 - e.cleanupMu.Lock() 363 - key := wid.String() 364 - 365 - fns := e.cleanup[key] 366 - delete(e.cleanup, key) 367 - e.cleanupMu.Unlock() 368 - 369 - for _, fn := range fns { 370 - if err := fn(ctx); err != nil { 371 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 372 - } 373 - } 374 - return nil 375 - } 376 - 377 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 378 - e.cleanupMu.Lock() 379 - defer e.cleanupMu.Unlock() 380 - 381 - key := wid.String() 382 - e.cleanup[key] = append(e.cleanup[key], fn) 383 - } 384 - 385 - func workspaceVolume(wid models.WorkflowId) string { 386 - return fmt.Sprintf("workspace-%s", wid) 387 - } 388 - 389 - func nixVolume(wid models.WorkflowId) string { 390 - return fmt.Sprintf("nix-%s", wid) 391 - } 392 - 393 - func networkName(wid models.WorkflowId) string { 394 - return fmt.Sprintf("workflow-network-%s", wid) 395 - } 396 - 397 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 398 - hostConfig := &container.HostConfig{ 399 - Mounts: []mount.Mount{ 400 - { 401 - Type: mount.TypeVolume, 402 - Source: workspaceVolume(wid), 403 - Target: workspaceDir, 404 - }, 405 - { 406 - Type: mount.TypeVolume, 407 - Source: nixVolume(wid), 408 - Target: "/nix", 409 - }, 410 - { 411 - Type: mount.TypeTmpfs, 412 - Target: "/tmp", 413 - ReadOnly: false, 414 - TmpfsOptions: &mount.TmpfsOptions{ 415 - Mode: 0o1777, // world-writeable sticky bit 416 - Options: [][]string{ 417 - {"exec"}, 418 - }, 419 - }, 420 - }, 421 - { 422 - Type: mount.TypeVolume, 423 - Source: "etc-nix-" + wid.String(), 424 - Target: "/etc/nix", 425 - }, 426 - }, 427 - ReadonlyRootfs: false, 428 - CapDrop: []string{"ALL"}, 429 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 430 - SecurityOpt: []string{"no-new-privileges"}, 431 - ExtraHosts: []string{"host.docker.internal:host-gateway"}, 432 - } 433 - 434 - return hostConfig 435 - } 436 - 437 - // thanks woodpecker 438 - func isErrContainerNotFoundOrNotRunning(err error) bool { 439 - // Error response from daemon: Cannot kill container: ...: No such container: ... 440 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 441 - // Error response from podman daemon: can only kill running containers. ... is in state exited 442 - // Error: No such container: ... 443 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 444 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.Equal(t, tt.want, got) 38 - }) 39 - } 40 - } 41 - 42 - func TestAddEnv(t *testing.T) { 43 - ev := EnvVars{} 44 - ev.AddEnv("FOO", "bar") 45 - ev.AddEnv("BAZ", "qux") 46 - want := EnvVars{"FOO=bar", "BAZ=qux"} 47 - assert.ElementsMatch(t, want, ev) 48 - }
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
-84
spindle/engine/logger.go
··· 1 - package engine 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "path/filepath" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 - ) 13 - 14 - type WorkflowLogger struct { 15 - file *os.File 16 - encoder *json.Encoder 17 - } 18 - 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - path := LogFilePath(baseDir, wid) 21 - 22 - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 23 - if err != nil { 24 - return nil, fmt.Errorf("creating log file: %w", err) 25 - } 26 - 27 - return &WorkflowLogger{ 28 - file: file, 29 - encoder: json.NewEncoder(file), 30 - }, nil 31 - } 32 - 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 - logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 - return logFilePath 36 - } 37 - 38 - func (l *WorkflowLogger) Close() error { 39 - return l.file.Close() 40 - } 41 - 42 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 - // TODO: emit stream 44 - return &dataWriter{ 45 - logger: l, 46 - stream: stream, 47 - } 48 - } 49 - 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 - return &controlWriter{ 52 - logger: l, 53 - idx: idx, 54 - step: step, 55 - } 56 - } 57 - 58 - type dataWriter struct { 59 - logger *WorkflowLogger 60 - stream string 61 - } 62 - 63 - func (w *dataWriter) Write(p []byte) (int, error) { 64 - line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 - if err := w.logger.encoder.Encode(entry); err != nil { 67 - return 0, err 68 - } 69 - return len(p), nil 70 - } 71 - 72 - type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step models.Step 76 - } 77 - 78 - func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 - if err := w.logger.encoder.Encode(entry); err != nil { 81 - return 0, err 82 - } 83 - return len(w.step.Name), nil 84 - }
+21
spindle/engines/nixery/ansi_stripper.go
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+418
spindle/engines/nixery/engine.go
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + // TODO(winter): investigate whether environment variables passed here 205 + // get propagated to ContainerExec processes 206 + }, &container.HostConfig{ 207 + Mounts: []mount.Mount{ 208 + { 209 + Type: mount.TypeTmpfs, 210 + Target: "/tmp", 211 + ReadOnly: false, 212 + TmpfsOptions: &mount.TmpfsOptions{ 213 + Mode: 0o1777, // world-writeable sticky bit 214 + Options: [][]string{ 215 + {"exec"}, 216 + }, 217 + }, 218 + }, 219 + }, 220 + ReadonlyRootfs: false, 221 + CapDrop: []string{"ALL"}, 222 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 223 + SecurityOpt: []string{"no-new-privileges"}, 224 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 225 + }, nil, nil, "") 226 + if err != nil { 227 + return fmt.Errorf("creating container: %w", err) 228 + } 229 + e.registerCleanup(wid, func(ctx context.Context) error { 230 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 231 + if err != nil { 232 + return err 233 + } 234 + 235 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 236 + RemoveVolumes: true, 237 + RemoveLinks: false, 238 + Force: false, 239 + }) 240 + }) 241 + 242 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 243 + if err != nil { 244 + return fmt.Errorf("starting container: %w", err) 245 + } 246 + 247 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 248 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 249 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 250 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 251 + }) 252 + if err != nil { 253 + return err 254 + } 255 + 256 + // This actually *starts* the command. Thanks, Docker! 257 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 258 + if err != nil { 259 + return err 260 + } 261 + defer execResp.Close() 262 + 263 + // This is apparently best way to wait for the command to complete. 264 + _, err = io.ReadAll(execResp.Reader) 265 + if err != nil { 266 + return err 267 + } 268 + 269 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 270 + if err != nil { 271 + return err 272 + } 273 + 274 + if execInspectResp.ExitCode != 0 { 275 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 276 + } else if execInspectResp.Running { 277 + return errors.New("mkdir is somehow still running??") 278 + } 279 + 280 + addl.container = resp.ID 281 + wf.Data = addl 282 + 283 + return nil 284 + } 285 + 286 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 287 + addl := w.Data.(addlFields) 288 + workflowEnvs := ConstructEnvs(addl.env) 289 + // TODO(winter): should SetupWorkflow also have secret access? 290 + // IMO yes, but probably worth thinking on. 291 + for _, s := range secrets { 292 + workflowEnvs.AddEnv(s.Key, s.Value) 293 + } 294 + 295 + step := w.Steps[idx].(Step) 296 + 297 + select { 298 + case <-ctx.Done(): 299 + return ctx.Err() 300 + default: 301 + } 302 + 303 + envs := append(EnvVars(nil), workflowEnvs...) 304 + for k, v := range step.environment { 305 + envs.AddEnv(k, v) 306 + } 307 + envs.AddEnv("HOME", homeDir) 308 + 309 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 310 + Cmd: []string{"bash", "-c", step.command}, 311 + AttachStdout: true, 312 + AttachStderr: true, 313 + Env: envs, 314 + }) 315 + if err != nil { 316 + return fmt.Errorf("creating exec: %w", err) 317 + } 318 + 319 + // start tailing logs in background 320 + tailDone := make(chan error, 1) 321 + go func() { 322 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 323 + }() 324 + 325 + select { 326 + case <-tailDone: 327 + 328 + case <-ctx.Done(): 329 + // cleanup will be handled by DestroyWorkflow, since 330 + // Docker doesn't provide an API to kill an exec run 331 + // (sure, we could grab the PID and kill it ourselves, 332 + // but that's wasted effort) 333 + e.l.Warn("step timed out", "step", step.Name) 334 + 335 + <-tailDone 336 + 337 + return engine.ErrTimedOut 338 + } 339 + 340 + select { 341 + case <-ctx.Done(): 342 + return ctx.Err() 343 + default: 344 + } 345 + 346 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 347 + if err != nil { 348 + return err 349 + } 350 + 351 + if execInspectResp.ExitCode != 0 { 352 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 353 + if err != nil { 354 + return err 355 + } 356 + 357 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 358 + 359 + if inspectResp.State.OOMKilled { 360 + return ErrOOMKilled 361 + } 362 + return engine.ErrWorkflowFailed 363 + } 364 + 365 + return nil 366 + } 367 + 368 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 369 + if wfLogger == nil { 370 + return nil 371 + } 372 + 373 + // This actually *starts* the command. Thanks, Docker! 374 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 375 + if err != nil { 376 + return err 377 + } 378 + defer logs.Close() 379 + 380 + _, err = stdcopy.StdCopy( 381 + wfLogger.DataWriter("stdout"), 382 + wfLogger.DataWriter("stderr"), 383 + logs.Reader, 384 + ) 385 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 386 + return fmt.Errorf("failed to copy logs: %w", err) 387 + } 388 + 389 + return nil 390 + } 391 + 392 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 393 + e.cleanupMu.Lock() 394 + key := wid.String() 395 + 396 + fns := e.cleanup[key] 397 + delete(e.cleanup, key) 398 + e.cleanupMu.Unlock() 399 + 400 + for _, fn := range fns { 401 + if err := fn(ctx); err != nil { 402 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 403 + } 404 + } 405 + return nil 406 + } 407 + 408 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 409 + e.cleanupMu.Lock() 410 + defer e.cleanupMu.Unlock() 411 + 412 + key := wid.String() 413 + e.cleanup[key] = append(e.cleanup[key], fn) 414 + } 415 + 416 + func networkName(wid models.WorkflowId) string { 417 + return fmt.Sprintf("workflow-network-%s", wid) 418 + }
+28
spindle/engines/nixery/envs.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+175 -9
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 8 + "time" 7 9 8 10 "tangled.sh/tangled.sh/core/api/tangled" 9 11 "tangled.sh/tangled.sh/core/eventconsumer" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 10 15 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/identity" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + "github.com/bluesky-social/indigo/xrpc" 11 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 12 22 ) 13 23 14 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 30 40 31 41 switch e.Commit.Collection { 32 42 case tangled.SpindleMemberNSID: 33 - s.ingestMember(ctx, e) 43 + err = s.ingestMember(ctx, e) 34 44 case tangled.RepoNSID: 35 - s.ingestRepo(ctx, e) 45 + err = s.ingestRepo(ctx, e) 46 + case tangled.RepoCollaboratorNSID: 47 + err = s.ingestCollaborator(ctx, e) 48 + } 49 + 50 + if err != nil { 51 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 36 52 } 37 53 38 - return err 54 + return nil 39 55 } 40 56 } 41 57 42 58 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 43 - did := e.Did 44 59 var err error 60 + did := e.Did 61 + rkey := e.Commit.RKey 45 62 46 63 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 47 64 ··· 56 73 } 57 74 58 75 domain := s.cfg.Server.Hostname 59 - if s.cfg.Server.Dev { 60 - domain = s.cfg.Server.ListenAddr 61 - } 62 76 recordInstance := record.Instance 63 77 64 78 if recordInstance != domain { ··· 72 86 return fmt.Errorf("failed to enforce permissions: %w", err) 73 87 } 74 88 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 89 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 90 + Did: syntax.DID(did), 91 + Rkey: rkey, 92 + Instance: recordInstance, 93 + Subject: syntax.DID(record.Subject), 94 + Created: time.Now(), 95 + }); err != nil { 96 + l.Error("failed to add member", "error", err) 97 + return fmt.Errorf("failed to add member: %w", err) 98 + } 99 + 100 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 76 101 l.Error("failed to add member", "error", err) 77 102 return fmt.Errorf("failed to add member: %w", err) 78 103 } ··· 86 111 87 112 return nil 88 113 114 + case models.CommitOperationDelete: 115 + record, err := db.GetSpindleMember(s.db, did, rkey) 116 + if err != nil { 117 + l.Error("failed to find member", "error", err) 118 + return fmt.Errorf("failed to find member: %w", err) 119 + } 120 + 121 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 122 + l.Error("failed to remove member", "error", err) 123 + return fmt.Errorf("failed to remove member: %w", err) 124 + } 125 + 126 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 127 + l.Error("failed to add member", "error", err) 128 + return fmt.Errorf("failed to add member: %w", err) 129 + } 130 + l.Info("added member from firehose", "member", record.Subject) 131 + 132 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 133 + l.Error("failed to add did", "error", err) 134 + return fmt.Errorf("failed to add did: %w", err) 135 + } 136 + s.jc.RemoveDid(record.Subject.String()) 137 + 89 138 } 90 139 return nil 91 140 } 92 141 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 142 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 94 143 var err error 144 + did := e.Did 145 + resolver := idresolver.DefaultResolver() 95 146 96 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 148 ··· 127 178 return fmt.Errorf("failed to add repo: %w", err) 128 179 } 129 180 181 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 182 + if err != nil { 183 + return err 184 + } 185 + 186 + // add repo to rbac 187 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 188 + l.Error("failed to add repo to enforcer", "error", err) 189 + return fmt.Errorf("failed to add repo: %w", err) 190 + } 191 + 192 + // add collaborators to rbac 193 + owner, err := resolver.ResolveIdent(ctx, did) 194 + if err != nil || owner.Handle.IsInvalidHandle() { 195 + return err 196 + } 197 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 198 + return err 199 + } 200 + 130 201 // add this knot to the event consumer 131 202 src := eventconsumer.NewKnotSource(record.Knot) 132 203 s.ks.AddSource(context.Background(), src) ··· 136 207 } 137 208 return nil 138 209 } 210 + 211 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 212 + var err error 213 + 214 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 215 + 216 + l.Info("ingesting collaborator record") 217 + 218 + switch e.Commit.Operation { 219 + case models.CommitOperationCreate, models.CommitOperationUpdate: 220 + raw := e.Commit.Record 221 + record := tangled.RepoCollaborator{} 222 + err = json.Unmarshal(raw, &record) 223 + if err != nil { 224 + l.Error("invalid record", "error", err) 225 + return err 226 + } 227 + 228 + resolver := idresolver.DefaultResolver() 229 + 230 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 231 + if err != nil || subjectId.Handle.IsInvalidHandle() { 232 + return err 233 + } 234 + 235 + repoAt, err := syntax.ParseATURI(record.Repo) 236 + if err != nil { 237 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 238 + return nil 239 + } 240 + 241 + // TODO: get rid of this entirely 242 + // resolve this aturi to extract the repo record 243 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 244 + if err != nil || owner.Handle.IsInvalidHandle() { 245 + return fmt.Errorf("failed to resolve handle: %w", err) 246 + } 247 + 248 + xrpcc := xrpc.Client{ 249 + Host: owner.PDSEndpoint(), 250 + } 251 + 252 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 253 + if err != nil { 254 + return err 255 + } 256 + 257 + repo := resp.Value.Val.(*tangled.Repo) 258 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 259 + 260 + // check perms for this user 261 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 262 + return fmt.Errorf("insufficient permissions: %w", err) 263 + } 264 + 265 + // add collaborator to rbac 266 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 267 + l.Error("failed to add repo to enforcer", "error", err) 268 + return fmt.Errorf("failed to add repo: %w", err) 269 + } 270 + 271 + return nil 272 + } 273 + return nil 274 + } 275 + 276 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 277 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 278 + 279 + l.Info("fetching and adding existing collaborators") 280 + 281 + xrpcc := xrpc.Client{ 282 + Host: owner.PDSEndpoint(), 283 + } 284 + 285 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 286 + if err != nil { 287 + return err 288 + } 289 + 290 + var errs error 291 + for _, r := range resp.Records { 292 + if r == nil { 293 + continue 294 + } 295 + record := r.Value.Val.(*tangled.RepoCollaborator) 296 + 297 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 298 + l.Error("failed to add repo to enforcer", "error", err) 299 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 300 + } 301 + } 302 + 303 + return errs 304 + }
+17
spindle/models/engine.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/spindle/secrets" 9 + ) 10 + 11 + type Engine interface { 12 + InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 + WorkflowTimeout() time.Duration 15 + DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 + }
+82
spindle/models/logger.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + type WorkflowLogger struct { 13 + file *os.File 14 + encoder *json.Encoder 15 + } 16 + 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + path := LogFilePath(baseDir, wid) 19 + 20 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 21 + if err != nil { 22 + return nil, fmt.Errorf("creating log file: %w", err) 23 + } 24 + 25 + return &WorkflowLogger{ 26 + file: file, 27 + encoder: json.NewEncoder(file), 28 + }, nil 29 + } 30 + 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 + logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 + return logFilePath 34 + } 35 + 36 + func (l *WorkflowLogger) Close() error { 37 + return l.file.Close() 38 + } 39 + 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 42 + return &dataWriter{ 43 + logger: l, 44 + stream: stream, 45 + } 46 + } 47 + 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 + return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + } 54 + } 55 + 56 + type dataWriter struct { 57 + logger *WorkflowLogger 58 + stream string 59 + } 60 + 61 + func (w *dataWriter) Write(p []byte) (int, error) { 62 + line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 + if err := w.logger.encoder.Encode(entry); err != nil { 65 + return 0, err 66 + } 67 + return len(p), nil 68 + } 69 + 70 + type controlWriter struct { 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 74 + } 75 + 76 + func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 + if err := w.logger.encoder.Encode(entry); err != nil { 79 + return 0, err 80 + } 81 + return len(w.step.Name()), nil 82 + }
+3 -3
spindle/models/models.go
··· 104 104 func NewControlLogLine(idx int, step Step) LogLine { 105 105 return LogLine{ 106 106 Kind: LogKindControl, 107 - Content: step.Name, 107 + Content: step.Name(), 108 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 111 } 112 112 }
+10 -108
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 - Workflows []Workflow 4 + RepoOwner string 5 + RepoName string 6 + Workflows map[Engine][]Workflow 12 7 } 13 8 14 - type Step struct { 15 - Command string 16 - Name string 17 - Environment map[string]string 18 - Kind StepKind 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 19 13 } 20 14 21 15 type StepKind int ··· 28 22 ) 29 23 30 24 type Workflow struct { 31 - Steps []Step 32 - Environment map[string]string 33 - Name string 34 - Image string 35 - } 36 - 37 - // setupSteps get added to start of Steps 38 - type setupSteps []Step 39 - 40 - // addStep adds a step to the beginning of the workflow's steps. 41 - func (ss *setupSteps) addStep(step Step) { 42 - *ss = append(*ss, step) 43 - } 44 - 45 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 46 - // In the process, dependencies are resolved: nixpkgs deps 47 - // are constructed atop nixery and set as the Workflow.Image, 48 - // and ones from custom registries 49 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 50 - workflows := []Workflow{} 51 - 52 - for _, twf := range pl.Workflows { 53 - swf := &Workflow{} 54 - for _, tstep := range twf.Steps { 55 - sstep := Step{} 56 - sstep.Environment = stepEnvToMap(tstep.Environment) 57 - sstep.Command = tstep.Command 58 - sstep.Name = tstep.Name 59 - sstep.Kind = StepKindUser 60 - swf.Steps = append(swf.Steps, sstep) 61 - } 62 - swf.Name = twf.Name 63 - swf.Environment = workflowEnvToMap(twf.Environment) 64 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 - 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - return &Pipeline{Workflows: workflows} 83 - } 84 - 85 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 86 - envMap := map[string]string{} 87 - for _, env := range envs { 88 - if env != nil { 89 - envMap[env.Key] = env.Value 90 - } 91 - } 92 - return envMap 93 - } 94 - 95 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 96 - envMap := map[string]string{} 97 - for _, env := range envs { 98 - if env != nil { 99 - envMap[env.Key] = env.Value 100 - } 101 - } 102 - return envMap 103 - } 104 - 105 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 106 - var dependencies string 107 - for _, d := range deps { 108 - if d.Registry == "nixpkgs" { 109 - dependencies = path.Join(d.Packages...) 110 - } 111 - } 112 - 113 - // load defaults from somewhere else 114 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 115 - 116 - return path.Join(nixery, dependencies) 117 - } 118 - 119 - func (wf *Workflow) addNixProfileToPath() { 120 - wf.Environment["PATH"] = "$PATH:/.nix-profile/bin" 121 - } 122 - 123 - func (wf *Workflow) setGlobalEnvs() { 124 - wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes" 125 - wf.Environment["HOME"] = "/tangled/workspace" 25 + Steps []Step 26 + Name string 27 + Data any 126 28 }
-125
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 - ) 11 - 12 - func nixConfStep() Step { 13 - setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 14 - echo 'build-users-group = ' >> /etc/nix/nix.conf` 15 - return Step{ 16 - Command: setupCmd, 17 - Name: "Configure Nix", 18 - } 19 - } 20 - 21 - // cloneOptsAsSteps processes clone options and adds corresponding steps 22 - // to the beginning of the workflow's step list if cloning is not skipped. 23 - // 24 - // the steps to do here are: 25 - // - git init 26 - // - git remote add origin <url> 27 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 28 - // - git checkout FETCH_HEAD 29 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 30 - if twf.Clone.Skip { 31 - return Step{} 32 - } 33 - 34 - var commands []string 35 - 36 - // initialize git repo in workspace 37 - commands = append(commands, "git init") 38 - 39 - // add repo as git remote 40 - scheme := "https://" 41 - if dev { 42 - scheme = "http://" 43 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 44 - } 45 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 46 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 47 - 48 - // run git fetch 49 - { 50 - var fetchArgs []string 51 - 52 - // default clone depth is 1 53 - depth := 1 54 - if twf.Clone.Depth > 1 { 55 - depth = int(twf.Clone.Depth) 56 - } 57 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 58 - 59 - // optionally recurse submodules 60 - if twf.Clone.Submodules { 61 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 62 - } 63 - 64 - // set remote to fetch from 65 - fetchArgs = append(fetchArgs, "origin") 66 - 67 - // set revision to checkout 68 - switch workflow.TriggerKind(tr.Kind) { 69 - case workflow.TriggerKindManual: 70 - // TODO: unimplemented 71 - case workflow.TriggerKindPush: 72 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 73 - case workflow.TriggerKindPullRequest: 74 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 75 - } 76 - 77 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 78 - } 79 - 80 - // run git checkout 81 - commands = append(commands, "git checkout FETCH_HEAD") 82 - 83 - cloneStep := Step{ 84 - Command: strings.Join(commands, "\n"), 85 - Name: "Clone repository into workspace", 86 - } 87 - return cloneStep 88 - } 89 - 90 - // dependencyStep processes dependencies defined in the workflow. 91 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 92 - // all packages and adds a single 'nix profile install' step to the 93 - // beginning of the workflow's step list. 94 - func dependencyStep(twf tangled.Pipeline_Workflow) *Step { 95 - var customPackages []string 96 - 97 - for _, d := range twf.Dependencies { 98 - registry := d.Registry 99 - packages := d.Packages 100 - 101 - if registry == "nixpkgs" { 102 - continue 103 - } 104 - 105 - // collect packages from custom registries 106 - for _, pkg := range packages { 107 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 108 - } 109 - } 110 - 111 - if len(customPackages) > 0 { 112 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 113 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 114 - installStep := Step{ 115 - Command: cmd, 116 - Name: "Install custom dependencies", 117 - Environment: map[string]string{ 118 - "NIX_NO_COLOR": "1", 119 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 120 - }, 121 - } 122 - return &installStep 123 - } 124 - return nil 125 - }
+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).DeleteMetadata(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+"?_foreign_keys=1") 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 + }
+129 -47
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" ··· 18 20 "tangled.sh/tangled.sh/core/spindle/config" 19 21 "tangled.sh/tangled.sh/core/spindle/db" 20 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 + "tangled.sh/tangled.sh/core/spindle/engines/nixery" 21 24 "tangled.sh/tangled.sh/core/spindle/models" 22 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 + "tangled.sh/tangled.sh/core/spindle/secrets" 27 + "tangled.sh/tangled.sh/core/spindle/xrpc" 23 28 ) 24 29 30 + //go:embed motd 31 + var motd []byte 32 + 25 33 const ( 26 34 rbacDomain = "thisserver" 27 35 ) 28 36 29 37 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 38 + jc *jetstream.JetstreamClient 39 + db *db.DB 40 + e *rbac.Enforcer 41 + l *slog.Logger 42 + n *notifier.Notifier 43 + engs map[string]models.Engine 44 + jq *queue.Queue 45 + cfg *config.Config 46 + ks *eventconsumer.Consumer 47 + res *idresolver.Resolver 48 + vault secrets.Manager 39 49 } 40 50 41 51 func Run(ctx context.Context) error { ··· 59 69 60 70 n := notifier.New() 61 71 62 - eng, err := engine.New(ctx, cfg, d, &n) 72 + var vault secrets.Manager 73 + switch cfg.Server.Secrets.Provider { 74 + case "openbao": 75 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 76 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 77 + } 78 + vault, err = secrets.NewOpenBaoManager( 79 + cfg.Server.Secrets.OpenBao.ProxyAddr, 80 + logger, 81 + secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 82 + ) 83 + if err != nil { 84 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 85 + } 86 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 87 + case "sqlite", "": 88 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 89 + if err != nil { 90 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 91 + } 92 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 93 + default: 94 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 95 + } 96 + 97 + nixeryEng, err := nixery.New(ctx, cfg) 63 98 if err != nil { 64 99 return err 65 100 } 66 101 67 - jq := queue.NewQueue(100, 2) 102 + jq := queue.NewQueue(100, 5) 68 103 69 104 collections := []string{ 70 105 tangled.SpindleMemberNSID, 71 106 tangled.RepoNSID, 107 + tangled.RepoCollaboratorNSID, 72 108 } 73 109 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 110 if err != nil { ··· 76 112 } 77 113 jc.AddDid(cfg.Server.Owner) 78 114 115 + // Check if the spindle knows about any Dids; 116 + dids, err := d.GetAllDids() 117 + if err != nil { 118 + return fmt.Errorf("failed to get all dids: %w", err) 119 + } 120 + for _, d := range dids { 121 + jc.AddDid(d) 122 + } 123 + 124 + resolver := idresolver.DefaultResolver() 125 + 79 126 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, 127 + jc: jc, 128 + e: e, 129 + db: d, 130 + l: logger, 131 + n: &n, 132 + engs: map[string]models.Engine{"nixery": nixeryEng}, 133 + jq: jq, 134 + cfg: cfg, 135 + res: resolver, 136 + vault: vault, 88 137 } 89 138 90 139 err = e.AddSpindle(rbacDomain) ··· 101 150 jq.Start() 102 151 defer jq.Stop() 103 152 153 + // Stop vault token renewal if it implements Stopper 154 + if stopper, ok := vault.(secrets.Stopper); ok { 155 + defer stopper.Stop() 156 + } 157 + 104 158 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 159 if err != nil { 106 160 return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) ··· 144 198 mux := chi.NewRouter() 145 199 146 200 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`)) 201 + w.Write(motd) 171 202 }) 172 203 mux.HandleFunc("/events", s.Events) 173 204 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 174 205 w.Write([]byte(s.cfg.Server.Owner)) 175 206 }) 176 207 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 208 + 209 + mux.Mount("/xrpc", s.XrpcRouter()) 177 210 return mux 178 211 } 179 212 213 + func (s *Spindle) XrpcRouter() http.Handler { 214 + logger := s.l.With("route", "xrpc") 215 + 216 + x := xrpc.Xrpc{ 217 + Logger: logger, 218 + Db: s.db, 219 + Enforcer: s.e, 220 + Engines: s.engs, 221 + Config: s.cfg, 222 + Resolver: s.res, 223 + Vault: s.vault, 224 + } 225 + 226 + return x.Router() 227 + } 228 + 180 229 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 181 230 if msg.Nsid == tangled.PipelineNSID { 182 231 tpl := tangled.Pipeline{} ··· 194 243 return fmt.Errorf("no repo data found") 195 244 } 196 245 246 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 247 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 248 + } 249 + 197 250 // filter by repos 198 251 _, err = s.db.GetRepo( 199 252 tpl.TriggerMetadata.Repo.Knot, ··· 209 262 Rkey: msg.Rkey, 210 263 } 211 264 265 + workflows := make(map[models.Engine][]models.Workflow) 266 + 212 267 for _, w := range tpl.Workflows { 213 268 if w != nil { 214 - err := s.db.StatusPending(models.WorkflowId{ 269 + if _, ok := s.engs[w.Engine]; !ok { 270 + err = s.db.StatusFailed(models.WorkflowId{ 271 + PipelineId: pipelineId, 272 + Name: w.Name, 273 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 274 + if err != nil { 275 + return err 276 + } 277 + 278 + continue 279 + } 280 + 281 + eng := s.engs[w.Engine] 282 + 283 + if _, ok := workflows[eng]; !ok { 284 + workflows[eng] = []models.Workflow{} 285 + } 286 + 287 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 288 + if err != nil { 289 + return err 290 + } 291 + 292 + workflows[eng] = append(workflows[eng], *ewf) 293 + 294 + err = s.db.StatusPending(models.WorkflowId{ 215 295 PipelineId: pipelineId, 216 296 Name: w.Name, 217 297 }, s.n) ··· 221 301 } 222 302 } 223 303 224 - spl := models.ToPipeline(tpl, *s.cfg) 225 - 226 304 ok := s.jq.Enqueue(queue.Job{ 227 305 Run: func() error { 228 - s.eng.StartWorkflows(ctx, spl, pipelineId) 306 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 307 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 308 + RepoName: tpl.TriggerMetadata.Repo.Repo, 309 + Workflows: workflows, 310 + }, pipelineId) 229 311 return nil 230 312 }, 231 313 OnFail: func(jobError error) {
+32 -2
spindle/stream.go
··· 6 6 "fmt" 7 7 "io" 8 8 "net/http" 9 + "os" 9 10 "strconv" 10 11 "time" 11 12 12 - "tangled.sh/tangled.sh/core/spindle/engine" 13 13 "tangled.sh/tangled.sh/core/spindle/models" 14 14 15 15 "github.com/go-chi/chi/v5" ··· 143 143 } 144 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 145 146 - filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid) 146 + filePath := models.LogFilePath(s.cfg.Server.LogDir, wid) 147 + 148 + if status.Status == models.StatusKindFailed.String() && status.Error != nil { 149 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 150 + msgs := []models.LogLine{ 151 + { 152 + Kind: models.LogKindControl, 153 + Content: "", 154 + StepId: 0, 155 + StepKind: models.StepKindUser, 156 + }, 157 + { 158 + Kind: models.LogKindData, 159 + Content: *status.Error, 160 + }, 161 + } 162 + 163 + for _, msg := range msgs { 164 + b, err := json.Marshal(msg) 165 + if err != nil { 166 + return err 167 + } 168 + 169 + if err := conn.WriteMessage(websocket.TextMessage, b); err != nil { 170 + return fmt.Errorf("failed to write to websocket: %w", err) 171 + } 172 + } 173 + 174 + return nil 175 + } 176 + } 147 177 148 178 config := tail.Config{ 149 179 Follow: !isFinished,
+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/models" 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 + Engines map[string]models.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 + }
+1 -3
tailwind.config.js
··· 36 36 css: { 37 37 maxWidth: "none", 38 38 pre: { 39 - backgroundColor: colors.gray[100], 40 - color: colors.black, 41 - "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 42 40 }, 43 41 code: { 44 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+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 + }
+62 -41
workflow/compile.go
··· 1 1 package workflow 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" 7 8 ) 8 9 10 + type RawWorkflow struct { 11 + Name string 12 + Contents []byte 13 + } 14 + 15 + type RawPipeline = []RawWorkflow 16 + 9 17 type Compiler struct { 10 18 Trigger tangled.Pipeline_TriggerMetadata 11 19 Diagnostics Diagnostics 12 20 } 13 21 14 22 type Diagnostics struct { 15 - Errors []error 23 + Errors []Error 16 24 Warnings []Warning 17 25 } 18 26 27 + func (d *Diagnostics) IsEmpty() bool { 28 + return len(d.Errors) == 0 && len(d.Warnings) == 0 29 + } 30 + 19 31 func (d *Diagnostics) Combine(o Diagnostics) { 20 32 d.Errors = append(d.Errors, o.Errors...) 21 33 d.Warnings = append(d.Warnings, o.Warnings...) ··· 25 37 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 26 38 } 27 39 28 - func (d *Diagnostics) AddError(err error) { 29 - d.Errors = append(d.Errors, err) 40 + func (d *Diagnostics) AddError(path string, err error) { 41 + d.Errors = append(d.Errors, Error{path, err}) 30 42 } 31 43 32 44 func (d Diagnostics) IsErr() bool { 33 45 return len(d.Errors) != 0 34 46 } 35 47 48 + type Error struct { 49 + Path string 50 + Error error 51 + } 52 + 53 + func (e Error) String() string { 54 + return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error()) 55 + } 56 + 36 57 type Warning struct { 37 58 Path string 38 59 Type WarningKind 39 60 Reason string 40 61 } 41 62 63 + func (w Warning) String() string { 64 + return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason) 65 + } 66 + 67 + var ( 68 + MissingEngine error = errors.New("missing engine") 69 + ) 70 + 42 71 type WarningKind string 43 72 44 73 var ( ··· 46 75 InvalidConfiguration WarningKind = "invalid configuration" 47 76 ) 48 77 78 + func (compiler *Compiler) Parse(p RawPipeline) Pipeline { 79 + var pp Pipeline 80 + 81 + for _, w := range p { 82 + wf, err := FromFile(w.Name, w.Contents) 83 + if err != nil { 84 + compiler.Diagnostics.AddError(w.Name, err) 85 + continue 86 + } 87 + 88 + pp = append(pp, wf) 89 + } 90 + 91 + return pp 92 + } 93 + 49 94 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 50 95 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 51 96 cp := tangled.Pipeline{ 52 97 TriggerMetadata: &compiler.Trigger, 53 98 } 54 99 55 - for _, w := range p { 56 - cw := compiler.compileWorkflow(w) 100 + for _, wf := range p { 101 + cw := compiler.compileWorkflow(wf) 57 102 58 - // empty workflows are not added to the pipeline 59 - if len(cw.Steps) == 0 { 103 + if cw == nil { 60 104 continue 61 105 } 62 106 63 - cp.Workflows = append(cp.Workflows, &cw) 107 + cp.Workflows = append(cp.Workflows, cw) 64 108 } 65 109 66 110 return cp 67 111 } 68 112 69 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 70 - cw := tangled.Pipeline_Workflow{} 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 71 115 72 116 if !w.Match(compiler.Trigger) { 73 117 compiler.Diagnostics.AddWarning( ··· 75 119 WorkflowSkipped, 76 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 77 121 ) 78 - return cw 79 - } 80 - 81 - if len(w.Steps) == 0 { 82 - compiler.Diagnostics.AddWarning( 83 - w.Name, 84 - WorkflowSkipped, 85 - "empty workflow", 86 - ) 87 - return cw 122 + return nil 88 123 } 89 124 90 125 // validate clone options 91 126 compiler.analyzeCloneOptions(w) 92 127 93 128 cw.Name = w.Name 94 - cw.Dependencies = w.Dependencies.AsRecord() 95 - for _, s := range w.Steps { 96 - step := tangled.Pipeline_Step{ 97 - Command: s.Command, 98 - Name: s.Name, 99 - } 100 - for k, v := range s.Environment { 101 - e := &tangled.Pipeline_Pair{ 102 - Key: k, 103 - Value: v, 104 - } 105 - step.Environment = append(step.Environment, e) 106 - } 107 - cw.Steps = append(cw.Steps, &step) 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 108 133 } 109 - for k, v := range w.Environment { 110 - e := &tangled.Pipeline_Pair{ 111 - Key: k, 112 - Value: v, 113 - } 114 - cw.Environment = append(cw.Environment, e) 115 - } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 116 137 117 138 o := w.CloneOpts.AsRecord() 118 139 cw.Clone = &o
+23 -29
workflow/compile_test.go
··· 26 26 27 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 34 32 CloneOpts: CloneOpts{}, // default true 35 33 } 36 34 ··· 43 41 assert.False(t, c.Diagnostics.IsErr()) 44 42 } 45 43 46 - func TestCompileWorkflow_EmptySteps(t *testing.T) { 47 - wf := Workflow{ 48 - Name: ".tangled/workflows/empty.yml", 49 - When: when, 50 - Steps: []Step{}, // no steps 51 - } 52 - 53 - c := Compiler{Trigger: trigger} 54 - cp := c.Compile([]Workflow{wf}) 55 - 56 - assert.Len(t, cp.Workflows, 0) 57 - assert.Len(t, c.Diagnostics.Warnings, 1) 58 - assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type) 59 - } 60 - 61 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 45 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 64 48 When: []Constraint{ 65 49 { 66 50 Event: []string{"push"}, 67 51 Branch: []string{"master"}, // different branch 68 52 }, 69 53 }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 - }, 73 54 } 74 55 75 56 c := Compiler{Trigger: trigger} ··· 82 63 83 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 65 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 90 69 CloneOpts: CloneOpts{ 91 70 Skip: true, 92 71 Depth: 1, ··· 101 80 assert.Len(t, c.Diagnostics.Warnings, 1) 102 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 82 } 83 + 84 + func TestCompileWorkflow_MissingEngine(t *testing.T) { 85 + wf := Workflow{ 86 + Name: ".tangled/workflows/missing_engine.yml", 87 + When: when, 88 + Engine: "", 89 + } 90 + 91 + c := Compiler{Trigger: trigger} 92 + cp := c.Compile([]Workflow{wf}) 93 + 94 + assert.Len(t, cp.Workflows, 0) 95 + assert.Len(t, c.Diagnostics.Errors, 1) 96 + assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 + }
+6 -33
workflow/def.go
··· 24 24 25 25 // this is simply a structural representation of the workflow file 26 26 Workflow struct { 27 - Name string `yaml:"-"` // name of the workflow file 28 - When []Constraint `yaml:"when"` 29 - Dependencies Dependencies `yaml:"dependencies"` 30 - Steps []Step `yaml:"steps"` 31 - Environment map[string]string `yaml:"environment"` 32 - CloneOpts CloneOpts `yaml:"clone"` 27 + Name string `yaml:"-"` // name of the workflow file 28 + Engine string `yaml:"engine"` 29 + When []Constraint `yaml:"when"` 30 + CloneOpts CloneOpts `yaml:"clone"` 31 + Raw string `yaml:"-"` 33 32 } 34 33 35 34 Constraint struct { 36 35 Event StringList `yaml:"event"` 37 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 38 37 } 39 - 40 - Dependencies map[string][]string 41 38 42 39 CloneOpts struct { 43 40 Skip bool `yaml:"skip"` 44 41 Depth int `yaml:"depth"` 45 42 IncludeSubmodules bool `yaml:"submodules"` 46 - } 47 - 48 - Step struct { 49 - Name string `yaml:"name"` 50 - Command string `yaml:"command"` 51 - Environment map[string]string `yaml:"environment"` 52 43 } 53 44 54 45 StringList []string ··· 77 68 } 78 69 79 70 wf.Name = name 71 + wf.Raw = string(contents) 80 72 81 73 return wf, nil 82 74 } ··· 173 165 } 174 166 175 167 return errors.New("failed to unmarshal StringOrSlice") 176 - } 177 - 178 - // conversion utilities to atproto records 179 - func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency { 180 - var deps []*tangled.Pipeline_Dependency 181 - for registry, packages := range d { 182 - deps = append(deps, &tangled.Pipeline_Dependency{ 183 - Registry: registry, 184 - Packages: packages, 185 - }) 186 - } 187 - return deps 188 - } 189 - 190 - func (s Step) AsRecord() tangled.Pipeline_Step { 191 - return tangled.Pipeline_Step{ 192 - Command: s.Command, 193 - Name: s.Name, 194 - } 195 168 } 196 169 197 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] 13 - branch: ["main", "develop"] 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go 18 - - git 19 - - curl 20 - 21 - steps: 22 - - name: "Test" 23 - command: | 24 - go test ./...` 13 + branch: ["main", "develop"]` 25 14 26 15 wf, err := FromFile("test.yml", []byte(yamlData)) 27 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 21 33 - assert.Len(t, wf.Steps, 1) 34 - assert.Equal(t, "Test", wf.Steps[0].Name) 35 - assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 - 37 - pkgs, ok := wf.Dependencies["nixpkgs"] 38 - assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 - assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 - 41 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 23 } 43 24 44 - func TestUnmarshalCustomRegistry(t *testing.T) { 45 - yamlData := ` 46 - when: 47 - - event: push 48 - branch: main 49 - 50 - dependencies: 51 - git+https://tangled.sh/@oppi.li/tbsp: 52 - - tbsp 53 - git+https://git.peppe.rs/languages/statix: 54 - - statix 55 - 56 - steps: 57 - - name: "Check" 58 - command: | 59 - statix check` 60 - 61 - wf, err := FromFile("test.yml", []byte(yamlData)) 62 - assert.NoError(t, err, "YAML should unmarshal without error") 63 - 64 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 - assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 - 67 - assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 - assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 - } 70 - 71 25 func TestUnmarshalCloneFalse(t *testing.T) { 72 26 yamlData := ` 73 27 when: ··· 75 29 76 30 clone: 77 31 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 32 ` 88 33 89 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 38 94 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 40 } 96 - 97 - func TestUnmarshalEnv(t *testing.T) { 98 - yamlData := ` 99 - when: 100 - - event: ["pull_request_close"] 101 - 102 - clone: 103 - skip: false 104 - 105 - environment: 106 - HOME: /home/foo bar/baz 107 - CGO_ENABLED: 1 108 - 109 - steps: 110 - - name: Something 111 - command: echo "hello" 112 - environment: 113 - FOO: bar 114 - BAZ: qux 115 - ` 116 - 117 - wf, err := FromFile("test.yml", []byte(yamlData)) 118 - assert.NoError(t, err) 119 - 120 - assert.Len(t, wf.Environment, 2) 121 - assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 122 - assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 123 - assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"]) 124 - assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"]) 125 - }