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

Compare changes

Choose any two refs to compare.

+20610 -11689
+1 -1
.air/appview.toml
··· 5 6 exclude_regex = [".*_templ.go"] 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"]
··· 5 6 exclude_regex = [".*_templ.go"] 7 include_ext = ["go", "templ", "html", "css"] 8 + exclude_dir = ["target", "atrium", "nix"]
+4
.gitignore
··· 14 .DS_Store 15 .env 16 *.rdb
··· 14 .DS_Store 15 .env 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 + }
+2
.tangled/workflows/build.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 dependencies: 6 nixpkgs: 7 - go
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 dependencies: 8 nixpkgs: 9 - go
+3 -12
.tangled/workflows/fmt.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 9 10 steps: 11 - - name: "nix fmt" 12 command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 16 - command: | 17 - unformatted=$(gofmt -l .) 18 - test -z "$unformatted" || (echo "$unformatted" && exit 1) 19 -
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 7 steps: 8 + - name: "Check formatting" 9 command: | 10 + nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 dependencies: 6 nixpkgs: 7 - go
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 dependencies: 8 nixpkgs: 9 - go
-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 - }
···
+1001 -1332
api/tangled/cbor_gen.go
··· 1202 1203 return nil 1204 } 1205 - func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error { 1206 - if t == nil { 1207 - _, err := w.Write(cbg.CborNull) 1208 - return err 1209 - } 1210 - 1211 - cw := cbg.NewCborWriter(w) 1212 - fieldCount := 3 1213 - 1214 - if t.LangBreakdown == nil { 1215 - fieldCount-- 1216 - } 1217 - 1218 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1219 - return err 1220 - } 1221 - 1222 - // t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct) 1223 - if len("commitCount") > 1000000 { 1224 - return xerrors.Errorf("Value in field \"commitCount\" was too long") 1225 - } 1226 - 1227 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil { 1228 - return err 1229 - } 1230 - if _, err := cw.WriteString(string("commitCount")); err != nil { 1231 - return err 1232 - } 1233 - 1234 - if err := t.CommitCount.MarshalCBOR(cw); err != nil { 1235 - return err 1236 - } 1237 - 1238 - // t.IsDefaultRef (bool) (bool) 1239 - if len("isDefaultRef") > 1000000 { 1240 - return xerrors.Errorf("Value in field \"isDefaultRef\" was too long") 1241 - } 1242 - 1243 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil { 1244 - return err 1245 - } 1246 - if _, err := cw.WriteString(string("isDefaultRef")); err != nil { 1247 - return err 1248 - } 1249 - 1250 - if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1251 - return err 1252 - } 1253 - 1254 - // t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct) 1255 - if t.LangBreakdown != nil { 1256 - 1257 - if len("langBreakdown") > 1000000 { 1258 - return xerrors.Errorf("Value in field \"langBreakdown\" was too long") 1259 - } 1260 - 1261 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil { 1262 - return err 1263 - } 1264 - if _, err := cw.WriteString(string("langBreakdown")); err != nil { 1265 - return err 1266 - } 1267 - 1268 - if err := t.LangBreakdown.MarshalCBOR(cw); err != nil { 1269 - return err 1270 - } 1271 - } 1272 - return nil 1273 - } 1274 - 1275 - func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) { 1276 - *t = GitRefUpdate_Meta{} 1277 - 1278 - cr := cbg.NewCborReader(r) 1279 - 1280 - maj, extra, err := cr.ReadHeader() 1281 - if err != nil { 1282 - return err 1283 - } 1284 - defer func() { 1285 - if err == io.EOF { 1286 - err = io.ErrUnexpectedEOF 1287 - } 1288 - }() 1289 - 1290 - if maj != cbg.MajMap { 1291 - return fmt.Errorf("cbor input should be of type map") 1292 - } 1293 - 1294 - if extra > cbg.MaxLength { 1295 - return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra) 1296 - } 1297 - 1298 - n := extra 1299 - 1300 - nameBuf := make([]byte, 13) 1301 - for i := uint64(0); i < n; i++ { 1302 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1303 - if err != nil { 1304 - return err 1305 - } 1306 - 1307 - if !ok { 1308 - // Field doesn't exist on this type, so ignore it 1309 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1310 - return err 1311 - } 1312 - continue 1313 - } 1314 - 1315 - switch string(nameBuf[:nameLen]) { 1316 - // t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct) 1317 - case "commitCount": 1318 - 1319 - { 1320 - 1321 - b, err := cr.ReadByte() 1322 - if err != nil { 1323 - return err 1324 - } 1325 - if b != cbg.CborNull[0] { 1326 - if err := cr.UnreadByte(); err != nil { 1327 - return err 1328 - } 1329 - t.CommitCount = new(GitRefUpdate_Meta_CommitCount) 1330 - if err := t.CommitCount.UnmarshalCBOR(cr); err != nil { 1331 - return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err) 1332 - } 1333 - } 1334 - 1335 - } 1336 - // t.IsDefaultRef (bool) (bool) 1337 - case "isDefaultRef": 1338 - 1339 - maj, extra, err = cr.ReadHeader() 1340 - if err != nil { 1341 - return err 1342 - } 1343 - if maj != cbg.MajOther { 1344 - return fmt.Errorf("booleans must be major type 7") 1345 - } 1346 - switch extra { 1347 - case 20: 1348 - t.IsDefaultRef = false 1349 - case 21: 1350 - t.IsDefaultRef = true 1351 - default: 1352 - return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1353 - } 1354 - // t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct) 1355 - case "langBreakdown": 1356 - 1357 - { 1358 - 1359 - b, err := cr.ReadByte() 1360 - if err != nil { 1361 - return err 1362 - } 1363 - if b != cbg.CborNull[0] { 1364 - if err := cr.UnreadByte(); err != nil { 1365 - return err 1366 - } 1367 - t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown) 1368 - if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil { 1369 - return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err) 1370 - } 1371 - } 1372 - 1373 - } 1374 - 1375 - default: 1376 - // Field doesn't exist on this type, so ignore it 1377 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1378 - return err 1379 - } 1380 - } 1381 - } 1382 - 1383 - return nil 1384 - } 1385 - func (t *GitRefUpdate_Meta_CommitCount) MarshalCBOR(w io.Writer) error { 1386 if t == nil { 1387 _, err := w.Write(cbg.CborNull) 1388 return err ··· 1399 return err 1400 } 1401 1402 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1403 if t.ByEmail != nil { 1404 1405 if len("byEmail") > 1000000 { ··· 1430 return nil 1431 } 1432 1433 - func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1434 - *t = GitRefUpdate_Meta_CommitCount{} 1435 1436 cr := cbg.NewCborReader(r) 1437 ··· 1450 } 1451 1452 if extra > cbg.MaxLength { 1453 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra) 1454 } 1455 1456 n := extra ··· 1471 } 1472 1473 switch string(nameBuf[:nameLen]) { 1474 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1475 case "byEmail": 1476 1477 maj, extra, err = cr.ReadHeader() ··· 1488 } 1489 1490 if extra > 0 { 1491 - t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra) 1492 } 1493 1494 for i := 0; i < int(extra); i++ { ··· 1510 if err := cr.UnreadByte(); err != nil { 1511 return err 1512 } 1513 - t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem) 1514 if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1515 return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1516 } ··· 1531 1532 return nil 1533 } 1534 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error { 1535 if t == nil { 1536 _, err := w.Write(cbg.CborNull) 1537 return err ··· 1590 return nil 1591 } 1592 1593 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) { 1594 - *t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{} 1595 1596 cr := cbg.NewCborReader(r) 1597 ··· 1610 } 1611 1612 if extra > cbg.MaxLength { 1613 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra) 1614 } 1615 1616 n := extra ··· 1679 1680 return nil 1681 } 1682 - func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error { 1683 if t == nil { 1684 _, err := w.Write(cbg.CborNull) 1685 return err ··· 1696 return err 1697 } 1698 1699 - // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1700 if t.Inputs != nil { 1701 1702 if len("inputs") > 1000000 { ··· 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 ··· 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 ··· 1768 } 1769 1770 switch string(nameBuf[:nameLen]) { 1771 - // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1772 case "inputs": 1773 1774 maj, extra, err = cr.ReadHeader() ··· 1785 } 1786 1787 if extra > 0 { 1788 - t.Inputs = make([]*GitRefUpdate_Pair, extra) 1789 } 1790 1791 for i := 0; i < int(extra); i++ { ··· 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 } ··· 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 ··· 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 ··· 1908 } 1909 1910 if extra > cbg.MaxLength { 1911 - return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra) 1912 } 1913 1914 n := extra ··· 1977 1978 return nil 1979 } 1980 func (t *GraphFollow) MarshalCBOR(w io.Writer) error { 1981 if t == nil { 1982 _, err := w.Write(cbg.CborNull) ··· 2118 } 2119 2120 t.Subject = string(sval) 2121 } 2122 // t.CreatedAt (string) (string) 2123 case "createdAt": ··· 2728 2729 return nil 2730 } 2731 - func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error { 2732 - if t == nil { 2733 - _, err := w.Write(cbg.CborNull) 2734 - return err 2735 - } 2736 - 2737 - cw := cbg.NewCborWriter(w) 2738 - 2739 - if _, err := cw.Write([]byte{162}); err != nil { 2740 - return err 2741 - } 2742 - 2743 - // t.Packages ([]string) (slice) 2744 - if len("packages") > 1000000 { 2745 - return xerrors.Errorf("Value in field \"packages\" was too long") 2746 - } 2747 - 2748 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil { 2749 - return err 2750 - } 2751 - if _, err := cw.WriteString(string("packages")); err != nil { 2752 - return err 2753 - } 2754 - 2755 - if len(t.Packages) > 8192 { 2756 - return xerrors.Errorf("Slice value in field t.Packages was too long") 2757 - } 2758 - 2759 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil { 2760 - return err 2761 - } 2762 - for _, v := range t.Packages { 2763 - if len(v) > 1000000 { 2764 - return xerrors.Errorf("Value in field v was too long") 2765 - } 2766 - 2767 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2768 - return err 2769 - } 2770 - if _, err := cw.WriteString(string(v)); err != nil { 2771 - return err 2772 - } 2773 - 2774 - } 2775 - 2776 - // t.Registry (string) (string) 2777 - if len("registry") > 1000000 { 2778 - return xerrors.Errorf("Value in field \"registry\" was too long") 2779 - } 2780 - 2781 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil { 2782 - return err 2783 - } 2784 - if _, err := cw.WriteString(string("registry")); err != nil { 2785 - return err 2786 - } 2787 - 2788 - if len(t.Registry) > 1000000 { 2789 - return xerrors.Errorf("Value in field t.Registry was too long") 2790 - } 2791 - 2792 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil { 2793 - return err 2794 - } 2795 - if _, err := cw.WriteString(string(t.Registry)); err != nil { 2796 - return err 2797 - } 2798 - return nil 2799 - } 2800 - 2801 - func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) { 2802 - *t = Pipeline_Dependency{} 2803 - 2804 - cr := cbg.NewCborReader(r) 2805 - 2806 - maj, extra, err := cr.ReadHeader() 2807 - if err != nil { 2808 - return err 2809 - } 2810 - defer func() { 2811 - if err == io.EOF { 2812 - err = io.ErrUnexpectedEOF 2813 - } 2814 - }() 2815 - 2816 - if maj != cbg.MajMap { 2817 - return fmt.Errorf("cbor input should be of type map") 2818 - } 2819 - 2820 - if extra > cbg.MaxLength { 2821 - return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra) 2822 - } 2823 - 2824 - n := extra 2825 - 2826 - nameBuf := make([]byte, 8) 2827 - for i := uint64(0); i < n; i++ { 2828 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2829 - if err != nil { 2830 - return err 2831 - } 2832 - 2833 - if !ok { 2834 - // Field doesn't exist on this type, so ignore it 2835 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2836 - return err 2837 - } 2838 - continue 2839 - } 2840 - 2841 - switch string(nameBuf[:nameLen]) { 2842 - // t.Packages ([]string) (slice) 2843 - case "packages": 2844 - 2845 - maj, extra, err = cr.ReadHeader() 2846 - if err != nil { 2847 - return err 2848 - } 2849 - 2850 - if extra > 8192 { 2851 - return fmt.Errorf("t.Packages: array too large (%d)", extra) 2852 - } 2853 - 2854 - if maj != cbg.MajArray { 2855 - return fmt.Errorf("expected cbor array") 2856 - } 2857 - 2858 - if extra > 0 { 2859 - t.Packages = make([]string, extra) 2860 - } 2861 - 2862 - for i := 0; i < int(extra); i++ { 2863 - { 2864 - var maj byte 2865 - var extra uint64 2866 - var err error 2867 - _ = maj 2868 - _ = extra 2869 - _ = err 2870 - 2871 - { 2872 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2873 - if err != nil { 2874 - return err 2875 - } 2876 - 2877 - t.Packages[i] = string(sval) 2878 - } 2879 - 2880 - } 2881 - } 2882 - // t.Registry (string) (string) 2883 - case "registry": 2884 - 2885 - { 2886 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2887 - if err != nil { 2888 - return err 2889 - } 2890 - 2891 - t.Registry = string(sval) 2892 - } 2893 - 2894 - default: 2895 - // Field doesn't exist on this type, so ignore it 2896 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2897 - return err 2898 - } 2899 - } 2900 - } 2901 - 2902 - return nil 2903 - } 2904 func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error { 2905 if t == nil { 2906 _, err := w.Write(cbg.CborNull) ··· 3916 3917 return nil 3918 } 3919 - func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error { 3920 - if t == nil { 3921 - _, err := w.Write(cbg.CborNull) 3922 - return err 3923 - } 3924 - 3925 - cw := cbg.NewCborWriter(w) 3926 - fieldCount := 3 3927 - 3928 - if t.Environment == nil { 3929 - fieldCount-- 3930 - } 3931 - 3932 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3933 - return err 3934 - } 3935 - 3936 - // t.Name (string) (string) 3937 - if len("name") > 1000000 { 3938 - return xerrors.Errorf("Value in field \"name\" was too long") 3939 - } 3940 - 3941 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 3942 - return err 3943 - } 3944 - if _, err := cw.WriteString(string("name")); err != nil { 3945 - return err 3946 - } 3947 - 3948 - if len(t.Name) > 1000000 { 3949 - return xerrors.Errorf("Value in field t.Name was too long") 3950 - } 3951 - 3952 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 3953 - return err 3954 - } 3955 - if _, err := cw.WriteString(string(t.Name)); err != nil { 3956 - return err 3957 - } 3958 - 3959 - // t.Command (string) (string) 3960 - if len("command") > 1000000 { 3961 - return xerrors.Errorf("Value in field \"command\" was too long") 3962 - } 3963 - 3964 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil { 3965 - return err 3966 - } 3967 - if _, err := cw.WriteString(string("command")); err != nil { 3968 - return err 3969 - } 3970 - 3971 - if len(t.Command) > 1000000 { 3972 - return xerrors.Errorf("Value in field t.Command was too long") 3973 - } 3974 - 3975 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil { 3976 - return err 3977 - } 3978 - if _, err := cw.WriteString(string(t.Command)); err != nil { 3979 - return err 3980 - } 3981 - 3982 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 3983 - if t.Environment != nil { 3984 - 3985 - if len("environment") > 1000000 { 3986 - return xerrors.Errorf("Value in field \"environment\" was too long") 3987 - } 3988 - 3989 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 3990 - return err 3991 - } 3992 - if _, err := cw.WriteString(string("environment")); err != nil { 3993 - return err 3994 - } 3995 - 3996 - if len(t.Environment) > 8192 { 3997 - return xerrors.Errorf("Slice value in field t.Environment was too long") 3998 - } 3999 - 4000 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4001 - return err 4002 - } 4003 - for _, v := range t.Environment { 4004 - if err := v.MarshalCBOR(cw); err != nil { 4005 - return err 4006 - } 4007 - 4008 - } 4009 - } 4010 - return nil 4011 - } 4012 - 4013 - func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) { 4014 - *t = Pipeline_Step{} 4015 - 4016 - cr := cbg.NewCborReader(r) 4017 - 4018 - maj, extra, err := cr.ReadHeader() 4019 - if err != nil { 4020 - return err 4021 - } 4022 - defer func() { 4023 - if err == io.EOF { 4024 - err = io.ErrUnexpectedEOF 4025 - } 4026 - }() 4027 - 4028 - if maj != cbg.MajMap { 4029 - return fmt.Errorf("cbor input should be of type map") 4030 - } 4031 - 4032 - if extra > cbg.MaxLength { 4033 - return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra) 4034 - } 4035 - 4036 - n := extra 4037 - 4038 - nameBuf := make([]byte, 11) 4039 - for i := uint64(0); i < n; i++ { 4040 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4041 - if err != nil { 4042 - return err 4043 - } 4044 - 4045 - if !ok { 4046 - // Field doesn't exist on this type, so ignore it 4047 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4048 - return err 4049 - } 4050 - continue 4051 - } 4052 - 4053 - switch string(nameBuf[:nameLen]) { 4054 - // t.Name (string) (string) 4055 - case "name": 4056 - 4057 - { 4058 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 4059 - if err != nil { 4060 - return err 4061 - } 4062 - 4063 - t.Name = string(sval) 4064 - } 4065 - // t.Command (string) (string) 4066 - case "command": 4067 - 4068 - { 4069 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 4070 - if err != nil { 4071 - return err 4072 - } 4073 - 4074 - t.Command = string(sval) 4075 - } 4076 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4077 - case "environment": 4078 - 4079 - maj, extra, err = cr.ReadHeader() 4080 - if err != nil { 4081 - return err 4082 - } 4083 - 4084 - if extra > 8192 { 4085 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4086 - } 4087 - 4088 - if maj != cbg.MajArray { 4089 - return fmt.Errorf("expected cbor array") 4090 - } 4091 - 4092 - if extra > 0 { 4093 - t.Environment = make([]*Pipeline_Pair, extra) 4094 - } 4095 - 4096 - for i := 0; i < int(extra); i++ { 4097 - { 4098 - var maj byte 4099 - var extra uint64 4100 - var err error 4101 - _ = maj 4102 - _ = extra 4103 - _ = err 4104 - 4105 - { 4106 - 4107 - b, err := cr.ReadByte() 4108 - if err != nil { 4109 - return err 4110 - } 4111 - if b != cbg.CborNull[0] { 4112 - if err := cr.UnreadByte(); err != nil { 4113 - return err 4114 - } 4115 - t.Environment[i] = new(Pipeline_Pair) 4116 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4117 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4118 - } 4119 - } 4120 - 4121 - } 4122 - 4123 - } 4124 - } 4125 - 4126 - default: 4127 - // Field doesn't exist on this type, so ignore it 4128 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4129 - return err 4130 - } 4131 - } 4132 - } 4133 - 4134 - return nil 4135 - } 4136 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 4137 if t == nil { 4138 _, err := w.Write(cbg.CborNull) ··· 4609 4610 cw := cbg.NewCborWriter(w) 4611 4612 - if _, err := cw.Write([]byte{165}); err != nil { 4613 return err 4614 } 4615 ··· 4652 return err 4653 } 4654 4655 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4656 - if len("steps") > 1000000 { 4657 - return xerrors.Errorf("Value in field \"steps\" was too long") 4658 - } 4659 - 4660 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4661 - return err 4662 - } 4663 - if _, err := cw.WriteString(string("steps")); err != nil { 4664 - return err 4665 } 4666 4667 - if len(t.Steps) > 8192 { 4668 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4669 - } 4670 - 4671 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4672 - return err 4673 - } 4674 - for _, v := range t.Steps { 4675 - if err := v.MarshalCBOR(cw); err != nil { 4676 - return err 4677 - } 4678 - 4679 - } 4680 - 4681 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4682 - if len("environment") > 1000000 { 4683 - return xerrors.Errorf("Value in field \"environment\" was too long") 4684 - } 4685 - 4686 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 4687 - return err 4688 - } 4689 - if _, err := cw.WriteString(string("environment")); err != nil { 4690 return err 4691 } 4692 - 4693 - if len(t.Environment) > 8192 { 4694 - return xerrors.Errorf("Slice value in field t.Environment was too long") 4695 - } 4696 - 4697 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4698 return err 4699 - } 4700 - for _, v := range t.Environment { 4701 - if err := v.MarshalCBOR(cw); err != nil { 4702 - return err 4703 - } 4704 - 4705 } 4706 4707 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4708 - if len("dependencies") > 1000000 { 4709 - return xerrors.Errorf("Value in field \"dependencies\" was too long") 4710 } 4711 4712 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil { 4713 return err 4714 } 4715 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4716 - return err 4717 - } 4718 - 4719 - if len(t.Dependencies) > 8192 { 4720 - return xerrors.Errorf("Slice value in field t.Dependencies was too long") 4721 - } 4722 - 4723 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil { 4724 return err 4725 - } 4726 - for _, v := range t.Dependencies { 4727 - if err := v.MarshalCBOR(cw); err != nil { 4728 - return err 4729 - } 4730 - 4731 } 4732 return nil 4733 } ··· 4757 4758 n := extra 4759 4760 - nameBuf := make([]byte, 12) 4761 for i := uint64(0); i < n; i++ { 4762 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4763 if err != nil { ··· 4773 } 4774 4775 switch string(nameBuf[:nameLen]) { 4776 - // t.Name (string) (string) 4777 case "name": 4778 4779 { ··· 4804 } 4805 4806 } 4807 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4808 - case "steps": 4809 4810 - maj, extra, err = cr.ReadHeader() 4811 - if err != nil { 4812 - return err 4813 - } 4814 - 4815 - if extra > 8192 { 4816 - return fmt.Errorf("t.Steps: array too large (%d)", extra) 4817 - } 4818 - 4819 - if maj != cbg.MajArray { 4820 - return fmt.Errorf("expected cbor array") 4821 - } 4822 - 4823 - if extra > 0 { 4824 - t.Steps = make([]*Pipeline_Step, extra) 4825 - } 4826 - 4827 - for i := 0; i < int(extra); i++ { 4828 - { 4829 - var maj byte 4830 - var extra uint64 4831 - var err error 4832 - _ = maj 4833 - _ = extra 4834 - _ = err 4835 - 4836 - { 4837 - 4838 - b, err := cr.ReadByte() 4839 - if err != nil { 4840 - return err 4841 - } 4842 - if b != cbg.CborNull[0] { 4843 - if err := cr.UnreadByte(); err != nil { 4844 - return err 4845 - } 4846 - t.Steps[i] = new(Pipeline_Step) 4847 - if err := t.Steps[i].UnmarshalCBOR(cr); err != nil { 4848 - return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err) 4849 - } 4850 - } 4851 - 4852 - } 4853 - 4854 } 4855 - } 4856 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4857 - case "environment": 4858 4859 - maj, extra, err = cr.ReadHeader() 4860 - if err != nil { 4861 - return err 4862 - } 4863 - 4864 - if extra > 8192 { 4865 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4866 - } 4867 - 4868 - if maj != cbg.MajArray { 4869 - return fmt.Errorf("expected cbor array") 4870 - } 4871 - 4872 - if extra > 0 { 4873 - t.Environment = make([]*Pipeline_Pair, extra) 4874 - } 4875 - 4876 - for i := 0; i < int(extra); i++ { 4877 - { 4878 - var maj byte 4879 - var extra uint64 4880 - var err error 4881 - _ = maj 4882 - _ = extra 4883 - _ = err 4884 - 4885 - { 4886 - 4887 - b, err := cr.ReadByte() 4888 - if err != nil { 4889 - return err 4890 - } 4891 - if b != cbg.CborNull[0] { 4892 - if err := cr.UnreadByte(); err != nil { 4893 - return err 4894 - } 4895 - t.Environment[i] = new(Pipeline_Pair) 4896 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4897 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4898 - } 4899 - } 4900 - 4901 - } 4902 - 4903 - } 4904 - } 4905 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4906 - case "dependencies": 4907 - 4908 - maj, extra, err = cr.ReadHeader() 4909 - if err != nil { 4910 - return err 4911 - } 4912 - 4913 - if extra > 8192 { 4914 - return fmt.Errorf("t.Dependencies: array too large (%d)", extra) 4915 - } 4916 - 4917 - if maj != cbg.MajArray { 4918 - return fmt.Errorf("expected cbor array") 4919 - } 4920 - 4921 - if extra > 0 { 4922 - t.Dependencies = make([]*Pipeline_Dependency, extra) 4923 - } 4924 - 4925 - for i := 0; i < int(extra); i++ { 4926 - { 4927 - var maj byte 4928 - var extra uint64 4929 - var err error 4930 - _ = maj 4931 - _ = extra 4932 - _ = err 4933 - 4934 - { 4935 - 4936 - b, err := cr.ReadByte() 4937 - if err != nil { 4938 - return err 4939 - } 4940 - if b != cbg.CborNull[0] { 4941 - if err := cr.UnreadByte(); err != nil { 4942 - return err 4943 - } 4944 - t.Dependencies[i] = new(Pipeline_Dependency) 4945 - if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil { 4946 - return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err) 4947 - } 4948 - } 4949 - 4950 - } 4951 - 4952 - } 4953 } 4954 4955 default: ··· 5854 5855 return nil 5856 } 5857 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5858 if t == nil { 5859 _, err := w.Write(cbg.CborNull) ··· 5861 } 5862 5863 cw := cbg.NewCborWriter(w) 5864 - fieldCount := 7 5865 5866 if t.Body == nil { 5867 fieldCount-- ··· 5945 return err 5946 } 5947 5948 - // t.Owner (string) (string) 5949 - if len("owner") > 1000000 { 5950 - return xerrors.Errorf("Value in field \"owner\" was too long") 5951 - } 5952 - 5953 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5954 - return err 5955 - } 5956 - if _, err := cw.WriteString(string("owner")); err != nil { 5957 - return err 5958 - } 5959 - 5960 - if len(t.Owner) > 1000000 { 5961 - return xerrors.Errorf("Value in field t.Owner was too long") 5962 - } 5963 - 5964 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil { 5965 - return err 5966 - } 5967 - if _, err := cw.WriteString(string(t.Owner)); err != nil { 5968 - return err 5969 - } 5970 - 5971 // t.Title (string) (string) 5972 if len("title") > 1000000 { 5973 return xerrors.Errorf("Value in field \"title\" was too long") ··· 5989 } 5990 if _, err := cw.WriteString(string(t.Title)); err != nil { 5991 return err 5992 - } 5993 - 5994 - // t.IssueId (int64) (int64) 5995 - if len("issueId") > 1000000 { 5996 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5997 - } 5998 - 5999 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 6000 - return err 6001 - } 6002 - if _, err := cw.WriteString(string("issueId")); err != nil { 6003 - return err 6004 - } 6005 - 6006 - if t.IssueId >= 0 { 6007 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 6008 - return err 6009 - } 6010 - } else { 6011 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 6012 - return err 6013 - } 6014 } 6015 6016 // t.CreatedAt (string) (string) ··· 6122 6123 t.LexiconTypeID = string(sval) 6124 } 6125 - // t.Owner (string) (string) 6126 - case "owner": 6127 - 6128 - { 6129 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6130 - if err != nil { 6131 - return err 6132 - } 6133 - 6134 - t.Owner = string(sval) 6135 - } 6136 // t.Title (string) (string) 6137 case "title": 6138 ··· 6144 6145 t.Title = string(sval) 6146 } 6147 - // t.IssueId (int64) (int64) 6148 - case "issueId": 6149 - { 6150 - maj, extra, err := cr.ReadHeader() 6151 - if err != nil { 6152 - return err 6153 - } 6154 - var extraI int64 6155 - switch maj { 6156 - case cbg.MajUnsignedInt: 6157 - extraI = int64(extra) 6158 - if extraI < 0 { 6159 - return fmt.Errorf("int64 positive overflow") 6160 - } 6161 - case cbg.MajNegativeInt: 6162 - extraI = int64(extra) 6163 - if extraI < 0 { 6164 - return fmt.Errorf("int64 negative overflow") 6165 - } 6166 - extraI = -1 - extraI 6167 - default: 6168 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6169 - } 6170 - 6171 - t.IssueId = int64(extraI) 6172 - } 6173 // t.CreatedAt (string) (string) 6174 case "createdAt": 6175 ··· 6199 } 6200 6201 cw := cbg.NewCborWriter(w) 6202 - fieldCount := 7 6203 6204 - if t.CommentId == nil { 6205 - fieldCount-- 6206 - } 6207 - 6208 - if t.Owner == nil { 6209 - fieldCount-- 6210 - } 6211 - 6212 - if t.Repo == nil { 6213 fieldCount-- 6214 } 6215 ··· 6240 return err 6241 } 6242 6243 - // t.Repo (string) (string) 6244 - if t.Repo != nil { 6245 - 6246 - if len("repo") > 1000000 { 6247 - return xerrors.Errorf("Value in field \"repo\" was too long") 6248 - } 6249 - 6250 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 6251 - return err 6252 - } 6253 - if _, err := cw.WriteString(string("repo")); err != nil { 6254 - return err 6255 - } 6256 - 6257 - if t.Repo == nil { 6258 - if _, err := cw.Write(cbg.CborNull); err != nil { 6259 - return err 6260 - } 6261 - } else { 6262 - if len(*t.Repo) > 1000000 { 6263 - return xerrors.Errorf("Value in field t.Repo was too long") 6264 - } 6265 - 6266 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 6267 - return err 6268 - } 6269 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 6270 - return err 6271 - } 6272 - } 6273 - } 6274 - 6275 // t.LexiconTypeID (string) (string) 6276 if len("$type") > 1000000 { 6277 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6314 return err 6315 } 6316 6317 - // t.Owner (string) (string) 6318 - if t.Owner != nil { 6319 6320 - if len("owner") > 1000000 { 6321 - return xerrors.Errorf("Value in field \"owner\" was too long") 6322 } 6323 6324 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 6325 return err 6326 } 6327 - if _, err := cw.WriteString(string("owner")); err != nil { 6328 return err 6329 } 6330 6331 - if t.Owner == nil { 6332 if _, err := cw.Write(cbg.CborNull); err != nil { 6333 return err 6334 } 6335 } else { 6336 - if len(*t.Owner) > 1000000 { 6337 - return xerrors.Errorf("Value in field t.Owner was too long") 6338 } 6339 6340 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 6341 return err 6342 } 6343 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6344 return err 6345 } 6346 } 6347 } 6348 6349 - // t.CommentId (int64) (int64) 6350 - if t.CommentId != nil { 6351 - 6352 - if len("commentId") > 1000000 { 6353 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6354 - } 6355 - 6356 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6357 - return err 6358 - } 6359 - if _, err := cw.WriteString(string("commentId")); err != nil { 6360 - return err 6361 - } 6362 - 6363 - if t.CommentId == nil { 6364 - if _, err := cw.Write(cbg.CborNull); err != nil { 6365 - return err 6366 - } 6367 - } else { 6368 - if *t.CommentId >= 0 { 6369 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6370 - return err 6371 - } 6372 - } else { 6373 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6374 - return err 6375 - } 6376 - } 6377 - } 6378 - 6379 - } 6380 - 6381 // t.CreatedAt (string) (string) 6382 if len("createdAt") > 1000000 { 6383 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6455 6456 t.Body = string(sval) 6457 } 6458 - // t.Repo (string) (string) 6459 - case "repo": 6460 - 6461 - { 6462 - b, err := cr.ReadByte() 6463 - if err != nil { 6464 - return err 6465 - } 6466 - if b != cbg.CborNull[0] { 6467 - if err := cr.UnreadByte(); err != nil { 6468 - return err 6469 - } 6470 - 6471 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6472 - if err != nil { 6473 - return err 6474 - } 6475 - 6476 - t.Repo = (*string)(&sval) 6477 - } 6478 - } 6479 // t.LexiconTypeID (string) (string) 6480 case "$type": 6481 ··· 6498 6499 t.Issue = string(sval) 6500 } 6501 - // t.Owner (string) (string) 6502 - case "owner": 6503 6504 { 6505 b, err := cr.ReadByte() ··· 6516 return err 6517 } 6518 6519 - t.Owner = (*string)(&sval) 6520 - } 6521 - } 6522 - // t.CommentId (int64) (int64) 6523 - case "commentId": 6524 - { 6525 - 6526 - b, err := cr.ReadByte() 6527 - if err != nil { 6528 - return err 6529 - } 6530 - if b != cbg.CborNull[0] { 6531 - if err := cr.UnreadByte(); err != nil { 6532 - return err 6533 - } 6534 - maj, extra, err := cr.ReadHeader() 6535 - if err != nil { 6536 - return err 6537 - } 6538 - var extraI int64 6539 - switch maj { 6540 - case cbg.MajUnsignedInt: 6541 - extraI = int64(extra) 6542 - if extraI < 0 { 6543 - return fmt.Errorf("int64 positive overflow") 6544 - } 6545 - case cbg.MajNegativeInt: 6546 - extraI = int64(extra) 6547 - if extraI < 0 { 6548 - return fmt.Errorf("int64 negative overflow") 6549 - } 6550 - extraI = -1 - extraI 6551 - default: 6552 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6553 - } 6554 - 6555 - t.CommentId = (*int64)(&extraI) 6556 } 6557 } 6558 // t.CreatedAt (string) (string) ··· 6748 } 6749 6750 cw := cbg.NewCborWriter(w) 6751 - fieldCount := 9 6752 6753 if t.Body == nil { 6754 fieldCount-- ··· 6859 return err 6860 } 6861 6862 - // t.PullId (int64) (int64) 6863 - if len("pullId") > 1000000 { 6864 - return xerrors.Errorf("Value in field \"pullId\" was too long") 6865 - } 6866 - 6867 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil { 6868 - return err 6869 - } 6870 - if _, err := cw.WriteString(string("pullId")); err != nil { 6871 - return err 6872 - } 6873 - 6874 - if t.PullId >= 0 { 6875 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil { 6876 - return err 6877 - } 6878 - } else { 6879 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil { 6880 - return err 6881 - } 6882 - } 6883 - 6884 // t.Source (tangled.RepoPull_Source) (struct) 6885 if t.Source != nil { 6886 ··· 6900 } 6901 } 6902 6903 - // t.CreatedAt (string) (string) 6904 - if len("createdAt") > 1000000 { 6905 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 6906 } 6907 6908 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6909 return err 6910 } 6911 - if _, err := cw.WriteString(string("createdAt")); err != nil { 6912 return err 6913 } 6914 6915 - if len(t.CreatedAt) > 1000000 { 6916 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 6917 - } 6918 - 6919 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6920 - return err 6921 - } 6922 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6923 return err 6924 } 6925 6926 - // t.TargetRepo (string) (string) 6927 - if len("targetRepo") > 1000000 { 6928 - return xerrors.Errorf("Value in field \"targetRepo\" was too long") 6929 - } 6930 - 6931 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil { 6932 - return err 6933 - } 6934 - if _, err := cw.WriteString(string("targetRepo")); err != nil { 6935 - return err 6936 - } 6937 - 6938 - if len(t.TargetRepo) > 1000000 { 6939 - return xerrors.Errorf("Value in field t.TargetRepo was too long") 6940 - } 6941 - 6942 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil { 6943 - return err 6944 - } 6945 - if _, err := cw.WriteString(string(t.TargetRepo)); err != nil { 6946 - return err 6947 - } 6948 - 6949 - // t.TargetBranch (string) (string) 6950 - if len("targetBranch") > 1000000 { 6951 - return xerrors.Errorf("Value in field \"targetBranch\" was too long") 6952 } 6953 6954 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil { 6955 return err 6956 } 6957 - if _, err := cw.WriteString(string("targetBranch")); err != nil { 6958 return err 6959 } 6960 6961 - if len(t.TargetBranch) > 1000000 { 6962 - return xerrors.Errorf("Value in field t.TargetBranch was too long") 6963 } 6964 6965 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil { 6966 return err 6967 } 6968 - if _, err := cw.WriteString(string(t.TargetBranch)); err != nil { 6969 return err 6970 } 6971 return nil ··· 6996 6997 n := extra 6998 6999 - nameBuf := make([]byte, 12) 7000 for i := uint64(0); i < n; i++ { 7001 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7002 if err != nil { ··· 7066 7067 t.Title = string(sval) 7068 } 7069 - // t.PullId (int64) (int64) 7070 - case "pullId": 7071 - { 7072 - maj, extra, err := cr.ReadHeader() 7073 - if err != nil { 7074 - return err 7075 - } 7076 - var extraI int64 7077 - switch maj { 7078 - case cbg.MajUnsignedInt: 7079 - extraI = int64(extra) 7080 - if extraI < 0 { 7081 - return fmt.Errorf("int64 positive overflow") 7082 - } 7083 - case cbg.MajNegativeInt: 7084 - extraI = int64(extra) 7085 - if extraI < 0 { 7086 - return fmt.Errorf("int64 negative overflow") 7087 - } 7088 - extraI = -1 - extraI 7089 - default: 7090 - return fmt.Errorf("wrong type for int64 field: %d", maj) 7091 - } 7092 - 7093 - t.PullId = int64(extraI) 7094 - } 7095 // t.Source (tangled.RepoPull_Source) (struct) 7096 case "source": 7097 ··· 7112 } 7113 7114 } 7115 - // t.CreatedAt (string) (string) 7116 - case "createdAt": 7117 7118 { 7119 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7120 if err != nil { 7121 return err 7122 } 7123 - 7124 - t.CreatedAt = string(sval) 7125 - } 7126 - // t.TargetRepo (string) (string) 7127 - case "targetRepo": 7128 - 7129 - { 7130 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7131 - if err != nil { 7132 - return err 7133 } 7134 7135 - t.TargetRepo = string(sval) 7136 } 7137 - // t.TargetBranch (string) (string) 7138 - case "targetBranch": 7139 7140 { 7141 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 7143 return err 7144 } 7145 7146 - t.TargetBranch = string(sval) 7147 } 7148 7149 default: ··· 7163 } 7164 7165 cw := cbg.NewCborWriter(w) 7166 - fieldCount := 7 7167 7168 - if t.CommentId == nil { 7169 - fieldCount-- 7170 - } 7171 - 7172 - if t.Owner == nil { 7173 - fieldCount-- 7174 - } 7175 - 7176 - if t.Repo == nil { 7177 - fieldCount-- 7178 - } 7179 - 7180 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7181 return err 7182 } 7183 ··· 7227 return err 7228 } 7229 7230 - // t.Repo (string) (string) 7231 - if t.Repo != nil { 7232 - 7233 - if len("repo") > 1000000 { 7234 - return xerrors.Errorf("Value in field \"repo\" was too long") 7235 - } 7236 - 7237 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7238 - return err 7239 - } 7240 - if _, err := cw.WriteString(string("repo")); err != nil { 7241 - return err 7242 - } 7243 - 7244 - if t.Repo == nil { 7245 - if _, err := cw.Write(cbg.CborNull); err != nil { 7246 - return err 7247 - } 7248 - } else { 7249 - if len(*t.Repo) > 1000000 { 7250 - return xerrors.Errorf("Value in field t.Repo was too long") 7251 - } 7252 - 7253 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 7254 - return err 7255 - } 7256 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 7257 - return err 7258 - } 7259 - } 7260 - } 7261 - 7262 // t.LexiconTypeID (string) (string) 7263 if len("$type") > 1000000 { 7264 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 7276 } 7277 if _, err := cw.WriteString(string("sh.tangled.repo.pull.comment")); err != nil { 7278 return err 7279 - } 7280 - 7281 - // t.Owner (string) (string) 7282 - if t.Owner != nil { 7283 - 7284 - if len("owner") > 1000000 { 7285 - return xerrors.Errorf("Value in field \"owner\" was too long") 7286 - } 7287 - 7288 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 7289 - return err 7290 - } 7291 - if _, err := cw.WriteString(string("owner")); err != nil { 7292 - return err 7293 - } 7294 - 7295 - if t.Owner == nil { 7296 - if _, err := cw.Write(cbg.CborNull); err != nil { 7297 - return err 7298 - } 7299 - } else { 7300 - if len(*t.Owner) > 1000000 { 7301 - return xerrors.Errorf("Value in field t.Owner was too long") 7302 - } 7303 - 7304 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 7305 - return err 7306 - } 7307 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 7308 - return err 7309 - } 7310 - } 7311 - } 7312 - 7313 - // t.CommentId (int64) (int64) 7314 - if t.CommentId != nil { 7315 - 7316 - if len("commentId") > 1000000 { 7317 - return xerrors.Errorf("Value in field \"commentId\" was too long") 7318 - } 7319 - 7320 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 7321 - return err 7322 - } 7323 - if _, err := cw.WriteString(string("commentId")); err != nil { 7324 - return err 7325 - } 7326 - 7327 - if t.CommentId == nil { 7328 - if _, err := cw.Write(cbg.CborNull); err != nil { 7329 - return err 7330 - } 7331 - } else { 7332 - if *t.CommentId >= 0 { 7333 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 7334 - return err 7335 - } 7336 - } else { 7337 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 7338 - return err 7339 - } 7340 - } 7341 - } 7342 - 7343 } 7344 7345 // t.CreatedAt (string) (string) ··· 7430 7431 t.Pull = string(sval) 7432 } 7433 - // t.Repo (string) (string) 7434 - case "repo": 7435 - 7436 - { 7437 - b, err := cr.ReadByte() 7438 - if err != nil { 7439 - return err 7440 - } 7441 - if b != cbg.CborNull[0] { 7442 - if err := cr.UnreadByte(); err != nil { 7443 - return err 7444 - } 7445 - 7446 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7447 - if err != nil { 7448 - return err 7449 - } 7450 - 7451 - t.Repo = (*string)(&sval) 7452 - } 7453 - } 7454 // t.LexiconTypeID (string) (string) 7455 case "$type": 7456 ··· 7461 } 7462 7463 t.LexiconTypeID = string(sval) 7464 - } 7465 - // t.Owner (string) (string) 7466 - case "owner": 7467 - 7468 - { 7469 - b, err := cr.ReadByte() 7470 - if err != nil { 7471 - return err 7472 - } 7473 - if b != cbg.CborNull[0] { 7474 - if err := cr.UnreadByte(); err != nil { 7475 - return err 7476 - } 7477 - 7478 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7479 - if err != nil { 7480 - return err 7481 - } 7482 - 7483 - t.Owner = (*string)(&sval) 7484 - } 7485 - } 7486 - // t.CommentId (int64) (int64) 7487 - case "commentId": 7488 - { 7489 - 7490 - b, err := cr.ReadByte() 7491 - if err != nil { 7492 - return err 7493 - } 7494 - if b != cbg.CborNull[0] { 7495 - if err := cr.UnreadByte(); err != nil { 7496 - return err 7497 - } 7498 - maj, extra, err := cr.ReadHeader() 7499 - if err != nil { 7500 - return err 7501 - } 7502 - var extraI int64 7503 - switch maj { 7504 - case cbg.MajUnsignedInt: 7505 - extraI = int64(extra) 7506 - if extraI < 0 { 7507 - return fmt.Errorf("int64 positive overflow") 7508 - } 7509 - case cbg.MajNegativeInt: 7510 - extraI = int64(extra) 7511 - if extraI < 0 { 7512 - return fmt.Errorf("int64 negative overflow") 7513 - } 7514 - extraI = -1 - extraI 7515 - default: 7516 - return fmt.Errorf("wrong type for int64 field: %d", maj) 7517 - } 7518 - 7519 - t.CommentId = (*int64)(&extraI) 7520 - } 7521 } 7522 // t.CreatedAt (string) (string) 7523 case "createdAt": ··· 7897 7898 return nil 7899 } 7900 func (t *Spindle) MarshalCBOR(w io.Writer) error { 7901 if t == nil { 7902 _, err := w.Write(cbg.CborNull) ··· 8225 8226 return nil 8227 }
··· 1202 1203 return nil 1204 } 1205 + func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error { 1206 if t == nil { 1207 _, err := w.Write(cbg.CborNull) 1208 return err ··· 1219 return err 1220 } 1221 1222 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1223 if t.ByEmail != nil { 1224 1225 if len("byEmail") > 1000000 { ··· 1250 return nil 1251 } 1252 1253 + func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1254 + *t = GitRefUpdate_CommitCountBreakdown{} 1255 1256 cr := cbg.NewCborReader(r) 1257 ··· 1270 } 1271 1272 if extra > cbg.MaxLength { 1273 + return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra) 1274 } 1275 1276 n := extra ··· 1291 } 1292 1293 switch string(nameBuf[:nameLen]) { 1294 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1295 case "byEmail": 1296 1297 maj, extra, err = cr.ReadHeader() ··· 1308 } 1309 1310 if extra > 0 { 1311 + t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra) 1312 } 1313 1314 for i := 0; i < int(extra); i++ { ··· 1330 if err := cr.UnreadByte(); err != nil { 1331 return err 1332 } 1333 + t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount) 1334 if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1335 return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1336 } ··· 1351 1352 return nil 1353 } 1354 + func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error { 1355 if t == nil { 1356 _, err := w.Write(cbg.CborNull) 1357 return err ··· 1410 return nil 1411 } 1412 1413 + func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1414 + *t = GitRefUpdate_IndividualEmailCommitCount{} 1415 1416 cr := cbg.NewCborReader(r) 1417 ··· 1430 } 1431 1432 if extra > cbg.MaxLength { 1433 + return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra) 1434 } 1435 1436 n := extra ··· 1499 1500 return nil 1501 } 1502 + func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1503 if t == nil { 1504 _, err := w.Write(cbg.CborNull) 1505 return err ··· 1516 return err 1517 } 1518 1519 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1520 if t.Inputs != nil { 1521 1522 if len("inputs") > 1000000 { ··· 1547 return nil 1548 } 1549 1550 + func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1551 + *t = GitRefUpdate_LangBreakdown{} 1552 1553 cr := cbg.NewCborReader(r) 1554 ··· 1567 } 1568 1569 if extra > cbg.MaxLength { 1570 + return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1571 } 1572 1573 n := extra ··· 1588 } 1589 1590 switch string(nameBuf[:nameLen]) { 1591 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1592 case "inputs": 1593 1594 maj, extra, err = cr.ReadHeader() ··· 1605 } 1606 1607 if extra > 0 { 1608 + t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1609 } 1610 1611 for i := 0; i < int(extra); i++ { ··· 1627 if err := cr.UnreadByte(); err != nil { 1628 return err 1629 } 1630 + t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize) 1631 if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1632 return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1633 } ··· 1648 1649 return nil 1650 } 1651 + func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1652 if t == nil { 1653 _, err := w.Write(cbg.CborNull) 1654 return err ··· 1708 return nil 1709 } 1710 1711 + func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1712 + *t = GitRefUpdate_IndividualLanguageSize{} 1713 1714 cr := cbg.NewCborReader(r) 1715 ··· 1728 } 1729 1730 if extra > cbg.MaxLength { 1731 + return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1732 } 1733 1734 n := extra ··· 1797 1798 return nil 1799 } 1800 + func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error { 1801 + if t == nil { 1802 + _, err := w.Write(cbg.CborNull) 1803 + return err 1804 + } 1805 + 1806 + cw := cbg.NewCborWriter(w) 1807 + fieldCount := 3 1808 + 1809 + if t.LangBreakdown == nil { 1810 + fieldCount-- 1811 + } 1812 + 1813 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1814 + return err 1815 + } 1816 + 1817 + // t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct) 1818 + if len("commitCount") > 1000000 { 1819 + return xerrors.Errorf("Value in field \"commitCount\" was too long") 1820 + } 1821 + 1822 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil { 1823 + return err 1824 + } 1825 + if _, err := cw.WriteString(string("commitCount")); err != nil { 1826 + return err 1827 + } 1828 + 1829 + if err := t.CommitCount.MarshalCBOR(cw); err != nil { 1830 + return err 1831 + } 1832 + 1833 + // t.IsDefaultRef (bool) (bool) 1834 + if len("isDefaultRef") > 1000000 { 1835 + return xerrors.Errorf("Value in field \"isDefaultRef\" was too long") 1836 + } 1837 + 1838 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil { 1839 + return err 1840 + } 1841 + if _, err := cw.WriteString(string("isDefaultRef")); err != nil { 1842 + return err 1843 + } 1844 + 1845 + if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1846 + return err 1847 + } 1848 + 1849 + // t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct) 1850 + if t.LangBreakdown != nil { 1851 + 1852 + if len("langBreakdown") > 1000000 { 1853 + return xerrors.Errorf("Value in field \"langBreakdown\" was too long") 1854 + } 1855 + 1856 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil { 1857 + return err 1858 + } 1859 + if _, err := cw.WriteString(string("langBreakdown")); err != nil { 1860 + return err 1861 + } 1862 + 1863 + if err := t.LangBreakdown.MarshalCBOR(cw); err != nil { 1864 + return err 1865 + } 1866 + } 1867 + return nil 1868 + } 1869 + 1870 + func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) { 1871 + *t = GitRefUpdate_Meta{} 1872 + 1873 + cr := cbg.NewCborReader(r) 1874 + 1875 + maj, extra, err := cr.ReadHeader() 1876 + if err != nil { 1877 + return err 1878 + } 1879 + defer func() { 1880 + if err == io.EOF { 1881 + err = io.ErrUnexpectedEOF 1882 + } 1883 + }() 1884 + 1885 + if maj != cbg.MajMap { 1886 + return fmt.Errorf("cbor input should be of type map") 1887 + } 1888 + 1889 + if extra > cbg.MaxLength { 1890 + return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra) 1891 + } 1892 + 1893 + n := extra 1894 + 1895 + nameBuf := make([]byte, 13) 1896 + for i := uint64(0); i < n; i++ { 1897 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1898 + if err != nil { 1899 + return err 1900 + } 1901 + 1902 + if !ok { 1903 + // Field doesn't exist on this type, so ignore it 1904 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1905 + return err 1906 + } 1907 + continue 1908 + } 1909 + 1910 + switch string(nameBuf[:nameLen]) { 1911 + // t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct) 1912 + case "commitCount": 1913 + 1914 + { 1915 + 1916 + b, err := cr.ReadByte() 1917 + if err != nil { 1918 + return err 1919 + } 1920 + if b != cbg.CborNull[0] { 1921 + if err := cr.UnreadByte(); err != nil { 1922 + return err 1923 + } 1924 + t.CommitCount = new(GitRefUpdate_CommitCountBreakdown) 1925 + if err := t.CommitCount.UnmarshalCBOR(cr); err != nil { 1926 + return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err) 1927 + } 1928 + } 1929 + 1930 + } 1931 + // t.IsDefaultRef (bool) (bool) 1932 + case "isDefaultRef": 1933 + 1934 + maj, extra, err = cr.ReadHeader() 1935 + if err != nil { 1936 + return err 1937 + } 1938 + if maj != cbg.MajOther { 1939 + return fmt.Errorf("booleans must be major type 7") 1940 + } 1941 + switch extra { 1942 + case 20: 1943 + t.IsDefaultRef = false 1944 + case 21: 1945 + t.IsDefaultRef = true 1946 + default: 1947 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1948 + } 1949 + // t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct) 1950 + case "langBreakdown": 1951 + 1952 + { 1953 + 1954 + b, err := cr.ReadByte() 1955 + if err != nil { 1956 + return err 1957 + } 1958 + if b != cbg.CborNull[0] { 1959 + if err := cr.UnreadByte(); err != nil { 1960 + return err 1961 + } 1962 + t.LangBreakdown = new(GitRefUpdate_LangBreakdown) 1963 + if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil { 1964 + return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err) 1965 + } 1966 + } 1967 + 1968 + } 1969 + 1970 + default: 1971 + // Field doesn't exist on this type, so ignore it 1972 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1973 + return err 1974 + } 1975 + } 1976 + } 1977 + 1978 + return nil 1979 + } 1980 func (t *GraphFollow) MarshalCBOR(w io.Writer) error { 1981 if t == nil { 1982 _, err := w.Write(cbg.CborNull) ··· 2118 } 2119 2120 t.Subject = string(sval) 2121 + } 2122 + // t.CreatedAt (string) (string) 2123 + case "createdAt": 2124 + 2125 + { 2126 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2127 + if err != nil { 2128 + return err 2129 + } 2130 + 2131 + t.CreatedAt = string(sval) 2132 + } 2133 + 2134 + default: 2135 + // Field doesn't exist on this type, so ignore it 2136 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2137 + return err 2138 + } 2139 + } 2140 + } 2141 + 2142 + return nil 2143 + } 2144 + func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 + if t == nil { 2146 + _, err := w.Write(cbg.CborNull) 2147 + return err 2148 + } 2149 + 2150 + cw := cbg.NewCborWriter(w) 2151 + 2152 + if _, err := cw.Write([]byte{162}); err != nil { 2153 + return err 2154 + } 2155 + 2156 + // t.LexiconTypeID (string) (string) 2157 + if len("$type") > 1000000 { 2158 + return xerrors.Errorf("Value in field \"$type\" was too long") 2159 + } 2160 + 2161 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 + return err 2163 + } 2164 + if _, err := cw.WriteString(string("$type")); err != nil { 2165 + return err 2166 + } 2167 + 2168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 + return err 2170 + } 2171 + if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 + return err 2173 + } 2174 + 2175 + // t.CreatedAt (string) (string) 2176 + if len("createdAt") > 1000000 { 2177 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 + } 2179 + 2180 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 + return err 2182 + } 2183 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 + return err 2185 + } 2186 + 2187 + if len(t.CreatedAt) > 1000000 { 2188 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 + } 2190 + 2191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 + return err 2193 + } 2194 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 + return err 2196 + } 2197 + return nil 2198 + } 2199 + 2200 + func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 + *t = Knot{} 2202 + 2203 + cr := cbg.NewCborReader(r) 2204 + 2205 + maj, extra, err := cr.ReadHeader() 2206 + if err != nil { 2207 + return err 2208 + } 2209 + defer func() { 2210 + if err == io.EOF { 2211 + err = io.ErrUnexpectedEOF 2212 + } 2213 + }() 2214 + 2215 + if maj != cbg.MajMap { 2216 + return fmt.Errorf("cbor input should be of type map") 2217 + } 2218 + 2219 + if extra > cbg.MaxLength { 2220 + return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 + } 2222 + 2223 + n := extra 2224 + 2225 + nameBuf := make([]byte, 9) 2226 + for i := uint64(0); i < n; i++ { 2227 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 + if err != nil { 2229 + return err 2230 + } 2231 + 2232 + if !ok { 2233 + // Field doesn't exist on this type, so ignore it 2234 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 + return err 2236 + } 2237 + continue 2238 + } 2239 + 2240 + switch string(nameBuf[:nameLen]) { 2241 + // t.LexiconTypeID (string) (string) 2242 + case "$type": 2243 + 2244 + { 2245 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 + if err != nil { 2247 + return err 2248 + } 2249 + 2250 + t.LexiconTypeID = string(sval) 2251 } 2252 // t.CreatedAt (string) (string) 2253 case "createdAt": ··· 2858 2859 return nil 2860 } 2861 func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error { 2862 if t == nil { 2863 _, err := w.Write(cbg.CborNull) ··· 3873 3874 return nil 3875 } 3876 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 3877 if t == nil { 3878 _, err := w.Write(cbg.CborNull) ··· 4349 4350 cw := cbg.NewCborWriter(w) 4351 4352 + if _, err := cw.Write([]byte{164}); err != nil { 4353 + return err 4354 + } 4355 + 4356 + // t.Raw (string) (string) 4357 + if len("raw") > 1000000 { 4358 + return xerrors.Errorf("Value in field \"raw\" was too long") 4359 + } 4360 + 4361 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4362 + return err 4363 + } 4364 + if _, err := cw.WriteString(string("raw")); err != nil { 4365 + return err 4366 + } 4367 + 4368 + if len(t.Raw) > 1000000 { 4369 + return xerrors.Errorf("Value in field t.Raw was too long") 4370 + } 4371 + 4372 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4373 + return err 4374 + } 4375 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4376 return err 4377 } 4378 ··· 4415 return err 4416 } 4417 4418 + // t.Engine (string) (string) 4419 + if len("engine") > 1000000 { 4420 + return xerrors.Errorf("Value in field \"engine\" was too long") 4421 } 4422 4423 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4424 return err 4425 } 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4427 return err 4428 } 4429 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4432 } 4433 4434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4435 return err 4436 } 4437 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4438 return err 4439 } 4440 return nil 4441 } ··· 4465 4466 n := extra 4467 4468 + nameBuf := make([]byte, 6) 4469 for i := uint64(0); i < n; i++ { 4470 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4471 if err != nil { ··· 4481 } 4482 4483 switch string(nameBuf[:nameLen]) { 4484 + // t.Raw (string) (string) 4485 + case "raw": 4486 + 4487 + { 4488 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4489 + if err != nil { 4490 + return err 4491 + } 4492 + 4493 + t.Raw = string(sval) 4494 + } 4495 + // t.Name (string) (string) 4496 case "name": 4497 4498 { ··· 4523 } 4524 4525 } 4526 + // t.Engine (string) (string) 4527 + case "engine": 4528 4529 + { 4530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4531 + if err != nil { 4532 + return err 4533 } 4534 4535 + t.Engine = string(sval) 4536 } 4537 4538 default: ··· 5437 5438 return nil 5439 } 5440 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5441 + if t == nil { 5442 + _, err := w.Write(cbg.CborNull) 5443 + return err 5444 + } 5445 + 5446 + cw := cbg.NewCborWriter(w) 5447 + 5448 + if _, err := cw.Write([]byte{164}); err != nil { 5449 + return err 5450 + } 5451 + 5452 + // t.Repo (string) (string) 5453 + if len("repo") > 1000000 { 5454 + return xerrors.Errorf("Value in field \"repo\" was too long") 5455 + } 5456 + 5457 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5458 + return err 5459 + } 5460 + if _, err := cw.WriteString(string("repo")); err != nil { 5461 + return err 5462 + } 5463 + 5464 + if len(t.Repo) > 1000000 { 5465 + return xerrors.Errorf("Value in field t.Repo was too long") 5466 + } 5467 + 5468 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5469 + return err 5470 + } 5471 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5472 + return err 5473 + } 5474 + 5475 + // t.LexiconTypeID (string) (string) 5476 + if len("$type") > 1000000 { 5477 + return xerrors.Errorf("Value in field \"$type\" was too long") 5478 + } 5479 + 5480 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5481 + return err 5482 + } 5483 + if _, err := cw.WriteString(string("$type")); err != nil { 5484 + return err 5485 + } 5486 + 5487 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5488 + return err 5489 + } 5490 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5491 + return err 5492 + } 5493 + 5494 + // t.Subject (string) (string) 5495 + if len("subject") > 1000000 { 5496 + return xerrors.Errorf("Value in field \"subject\" was too long") 5497 + } 5498 + 5499 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5500 + return err 5501 + } 5502 + if _, err := cw.WriteString(string("subject")); err != nil { 5503 + return err 5504 + } 5505 + 5506 + if len(t.Subject) > 1000000 { 5507 + return xerrors.Errorf("Value in field t.Subject was too long") 5508 + } 5509 + 5510 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5511 + return err 5512 + } 5513 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5514 + return err 5515 + } 5516 + 5517 + // t.CreatedAt (string) (string) 5518 + if len("createdAt") > 1000000 { 5519 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5520 + } 5521 + 5522 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5523 + return err 5524 + } 5525 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5526 + return err 5527 + } 5528 + 5529 + if len(t.CreatedAt) > 1000000 { 5530 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5531 + } 5532 + 5533 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5534 + return err 5535 + } 5536 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5537 + return err 5538 + } 5539 + return nil 5540 + } 5541 + 5542 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5543 + *t = RepoCollaborator{} 5544 + 5545 + cr := cbg.NewCborReader(r) 5546 + 5547 + maj, extra, err := cr.ReadHeader() 5548 + if err != nil { 5549 + return err 5550 + } 5551 + defer func() { 5552 + if err == io.EOF { 5553 + err = io.ErrUnexpectedEOF 5554 + } 5555 + }() 5556 + 5557 + if maj != cbg.MajMap { 5558 + return fmt.Errorf("cbor input should be of type map") 5559 + } 5560 + 5561 + if extra > cbg.MaxLength { 5562 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5563 + } 5564 + 5565 + n := extra 5566 + 5567 + nameBuf := make([]byte, 9) 5568 + for i := uint64(0); i < n; i++ { 5569 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5570 + if err != nil { 5571 + return err 5572 + } 5573 + 5574 + if !ok { 5575 + // Field doesn't exist on this type, so ignore it 5576 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5577 + return err 5578 + } 5579 + continue 5580 + } 5581 + 5582 + switch string(nameBuf[:nameLen]) { 5583 + // t.Repo (string) (string) 5584 + case "repo": 5585 + 5586 + { 5587 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5588 + if err != nil { 5589 + return err 5590 + } 5591 + 5592 + t.Repo = string(sval) 5593 + } 5594 + // t.LexiconTypeID (string) (string) 5595 + case "$type": 5596 + 5597 + { 5598 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5599 + if err != nil { 5600 + return err 5601 + } 5602 + 5603 + t.LexiconTypeID = string(sval) 5604 + } 5605 + // t.Subject (string) (string) 5606 + case "subject": 5607 + 5608 + { 5609 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5610 + if err != nil { 5611 + return err 5612 + } 5613 + 5614 + t.Subject = string(sval) 5615 + } 5616 + // t.CreatedAt (string) (string) 5617 + case "createdAt": 5618 + 5619 + { 5620 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5621 + if err != nil { 5622 + return err 5623 + } 5624 + 5625 + t.CreatedAt = string(sval) 5626 + } 5627 + 5628 + default: 5629 + // Field doesn't exist on this type, so ignore it 5630 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5631 + return err 5632 + } 5633 + } 5634 + } 5635 + 5636 + return nil 5637 + } 5638 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5639 if t == nil { 5640 _, err := w.Write(cbg.CborNull) ··· 5642 } 5643 5644 cw := cbg.NewCborWriter(w) 5645 + fieldCount := 5 5646 5647 if t.Body == nil { 5648 fieldCount-- ··· 5726 return err 5727 } 5728 5729 // t.Title (string) (string) 5730 if len("title") > 1000000 { 5731 return xerrors.Errorf("Value in field \"title\" was too long") ··· 5747 } 5748 if _, err := cw.WriteString(string(t.Title)); err != nil { 5749 return err 5750 } 5751 5752 // t.CreatedAt (string) (string) ··· 5858 5859 t.LexiconTypeID = string(sval) 5860 } 5861 // t.Title (string) (string) 5862 case "title": 5863 ··· 5869 5870 t.Title = string(sval) 5871 } 5872 // t.CreatedAt (string) (string) 5873 case "createdAt": 5874 ··· 5898 } 5899 5900 cw := cbg.NewCborWriter(w) 5901 + fieldCount := 5 5902 5903 + if t.ReplyTo == nil { 5904 fieldCount-- 5905 } 5906 ··· 5931 return err 5932 } 5933 5934 // t.LexiconTypeID (string) (string) 5935 if len("$type") > 1000000 { 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 5973 return err 5974 } 5975 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 5978 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 5981 } 5982 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 5984 return err 5985 } 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 5987 return err 5988 } 5989 5990 + if t.ReplyTo == nil { 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 5992 return err 5993 } 5994 } else { 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 5997 } 5998 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6000 return err 6001 } 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6003 return err 6004 } 6005 } 6006 } 6007 6008 // t.CreatedAt (string) (string) 6009 if len("createdAt") > 1000000 { 6010 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6082 6083 t.Body = string(sval) 6084 } 6085 // t.LexiconTypeID (string) (string) 6086 case "$type": 6087 ··· 6104 6105 t.Issue = string(sval) 6106 } 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6109 6110 { 6111 b, err := cr.ReadByte() ··· 6122 return err 6123 } 6124 6125 + t.ReplyTo = (*string)(&sval) 6126 } 6127 } 6128 // t.CreatedAt (string) (string) ··· 6318 } 6319 6320 cw := cbg.NewCborWriter(w) 6321 + fieldCount := 7 6322 6323 if t.Body == nil { 6324 fieldCount-- ··· 6429 return err 6430 } 6431 6432 // t.Source (tangled.RepoPull_Source) (struct) 6433 if t.Source != nil { 6434 ··· 6448 } 6449 } 6450 6451 + // t.Target (tangled.RepoPull_Target) (struct) 6452 + if len("target") > 1000000 { 6453 + return xerrors.Errorf("Value in field \"target\" was too long") 6454 } 6455 6456 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil { 6457 return err 6458 } 6459 + if _, err := cw.WriteString(string("target")); err != nil { 6460 return err 6461 } 6462 6463 + if err := t.Target.MarshalCBOR(cw); err != nil { 6464 return err 6465 } 6466 6467 + // t.CreatedAt (string) (string) 6468 + if len("createdAt") > 1000000 { 6469 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 6470 } 6471 6472 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6473 return err 6474 } 6475 + if _, err := cw.WriteString(string("createdAt")); err != nil { 6476 return err 6477 } 6478 6479 + if len(t.CreatedAt) > 1000000 { 6480 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 6481 } 6482 6483 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6484 return err 6485 } 6486 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6487 return err 6488 } 6489 return nil ··· 6514 6515 n := extra 6516 6517 + nameBuf := make([]byte, 9) 6518 for i := uint64(0); i < n; i++ { 6519 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6520 if err != nil { ··· 6584 6585 t.Title = string(sval) 6586 } 6587 // t.Source (tangled.RepoPull_Source) (struct) 6588 case "source": 6589 ··· 6604 } 6605 6606 } 6607 + // t.Target (tangled.RepoPull_Target) (struct) 6608 + case "target": 6609 6610 { 6611 + 6612 + b, err := cr.ReadByte() 6613 if err != nil { 6614 return err 6615 } 6616 + if b != cbg.CborNull[0] { 6617 + if err := cr.UnreadByte(); err != nil { 6618 + return err 6619 + } 6620 + t.Target = new(RepoPull_Target) 6621 + if err := t.Target.UnmarshalCBOR(cr); err != nil { 6622 + return xerrors.Errorf("unmarshaling t.Target pointer: %w", err) 6623 + } 6624 } 6625 6626 } 6627 + // t.CreatedAt (string) (string) 6628 + case "createdAt": 6629 6630 { 6631 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 6633 return err 6634 } 6635 6636 + t.CreatedAt = string(sval) 6637 } 6638 6639 default: ··· 6653 } 6654 6655 cw := cbg.NewCborWriter(w) 6656 6657 + if _, err := cw.Write([]byte{164}); err != nil { 6658 return err 6659 } 6660 ··· 6704 return err 6705 } 6706 6707 // t.LexiconTypeID (string) (string) 6708 if len("$type") > 1000000 { 6709 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6721 } 6722 if _, err := cw.WriteString(string("sh.tangled.repo.pull.comment")); err != nil { 6723 return err 6724 } 6725 6726 // t.CreatedAt (string) (string) ··· 6811 6812 t.Pull = string(sval) 6813 } 6814 // t.LexiconTypeID (string) (string) 6815 case "$type": 6816 ··· 6821 } 6822 6823 t.LexiconTypeID = string(sval) 6824 } 6825 // t.CreatedAt (string) (string) 6826 case "createdAt": ··· 7200 7201 return nil 7202 } 7203 + func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error { 7204 + if t == nil { 7205 + _, err := w.Write(cbg.CborNull) 7206 + return err 7207 + } 7208 + 7209 + cw := cbg.NewCborWriter(w) 7210 + 7211 + if _, err := cw.Write([]byte{162}); err != nil { 7212 + return err 7213 + } 7214 + 7215 + // t.Repo (string) (string) 7216 + if len("repo") > 1000000 { 7217 + return xerrors.Errorf("Value in field \"repo\" was too long") 7218 + } 7219 + 7220 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7221 + return err 7222 + } 7223 + if _, err := cw.WriteString(string("repo")); err != nil { 7224 + return err 7225 + } 7226 + 7227 + if len(t.Repo) > 1000000 { 7228 + return xerrors.Errorf("Value in field t.Repo was too long") 7229 + } 7230 + 7231 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 7232 + return err 7233 + } 7234 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 7235 + return err 7236 + } 7237 + 7238 + // t.Branch (string) (string) 7239 + if len("branch") > 1000000 { 7240 + return xerrors.Errorf("Value in field \"branch\" was too long") 7241 + } 7242 + 7243 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil { 7244 + return err 7245 + } 7246 + if _, err := cw.WriteString(string("branch")); err != nil { 7247 + return err 7248 + } 7249 + 7250 + if len(t.Branch) > 1000000 { 7251 + return xerrors.Errorf("Value in field t.Branch was too long") 7252 + } 7253 + 7254 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil { 7255 + return err 7256 + } 7257 + if _, err := cw.WriteString(string(t.Branch)); err != nil { 7258 + return err 7259 + } 7260 + return nil 7261 + } 7262 + 7263 + func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) { 7264 + *t = RepoPull_Target{} 7265 + 7266 + cr := cbg.NewCborReader(r) 7267 + 7268 + maj, extra, err := cr.ReadHeader() 7269 + if err != nil { 7270 + return err 7271 + } 7272 + defer func() { 7273 + if err == io.EOF { 7274 + err = io.ErrUnexpectedEOF 7275 + } 7276 + }() 7277 + 7278 + if maj != cbg.MajMap { 7279 + return fmt.Errorf("cbor input should be of type map") 7280 + } 7281 + 7282 + if extra > cbg.MaxLength { 7283 + return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra) 7284 + } 7285 + 7286 + n := extra 7287 + 7288 + nameBuf := make([]byte, 6) 7289 + for i := uint64(0); i < n; i++ { 7290 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7291 + if err != nil { 7292 + return err 7293 + } 7294 + 7295 + if !ok { 7296 + // Field doesn't exist on this type, so ignore it 7297 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7298 + return err 7299 + } 7300 + continue 7301 + } 7302 + 7303 + switch string(nameBuf[:nameLen]) { 7304 + // t.Repo (string) (string) 7305 + case "repo": 7306 + 7307 + { 7308 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7309 + if err != nil { 7310 + return err 7311 + } 7312 + 7313 + t.Repo = string(sval) 7314 + } 7315 + // t.Branch (string) (string) 7316 + case "branch": 7317 + 7318 + { 7319 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7320 + if err != nil { 7321 + return err 7322 + } 7323 + 7324 + t.Branch = string(sval) 7325 + } 7326 + 7327 + default: 7328 + // Field doesn't exist on this type, so ignore it 7329 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7330 + return err 7331 + } 7332 + } 7333 + } 7334 + 7335 + return nil 7336 + } 7337 func (t *Spindle) MarshalCBOR(w io.Writer) error { 7338 if t == nil { 7339 _, err := w.Write(cbg.CborNull) ··· 7662 7663 return nil 7664 } 7665 + func (t *String) MarshalCBOR(w io.Writer) error { 7666 + if t == nil { 7667 + _, err := w.Write(cbg.CborNull) 7668 + return err 7669 + } 7670 + 7671 + cw := cbg.NewCborWriter(w) 7672 + 7673 + if _, err := cw.Write([]byte{165}); err != nil { 7674 + return err 7675 + } 7676 + 7677 + // t.LexiconTypeID (string) (string) 7678 + if len("$type") > 1000000 { 7679 + return xerrors.Errorf("Value in field \"$type\" was too long") 7680 + } 7681 + 7682 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 7683 + return err 7684 + } 7685 + if _, err := cw.WriteString(string("$type")); err != nil { 7686 + return err 7687 + } 7688 + 7689 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 7690 + return err 7691 + } 7692 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 7693 + return err 7694 + } 7695 + 7696 + // t.Contents (string) (string) 7697 + if len("contents") > 1000000 { 7698 + return xerrors.Errorf("Value in field \"contents\" was too long") 7699 + } 7700 + 7701 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 7702 + return err 7703 + } 7704 + if _, err := cw.WriteString(string("contents")); err != nil { 7705 + return err 7706 + } 7707 + 7708 + if len(t.Contents) > 1000000 { 7709 + return xerrors.Errorf("Value in field t.Contents was too long") 7710 + } 7711 + 7712 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 7713 + return err 7714 + } 7715 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 7716 + return err 7717 + } 7718 + 7719 + // t.Filename (string) (string) 7720 + if len("filename") > 1000000 { 7721 + return xerrors.Errorf("Value in field \"filename\" was too long") 7722 + } 7723 + 7724 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 7725 + return err 7726 + } 7727 + if _, err := cw.WriteString(string("filename")); err != nil { 7728 + return err 7729 + } 7730 + 7731 + if len(t.Filename) > 1000000 { 7732 + return xerrors.Errorf("Value in field t.Filename was too long") 7733 + } 7734 + 7735 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 7736 + return err 7737 + } 7738 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 7739 + return err 7740 + } 7741 + 7742 + // t.CreatedAt (string) (string) 7743 + if len("createdAt") > 1000000 { 7744 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7745 + } 7746 + 7747 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7748 + return err 7749 + } 7750 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7751 + return err 7752 + } 7753 + 7754 + if len(t.CreatedAt) > 1000000 { 7755 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7756 + } 7757 + 7758 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7759 + return err 7760 + } 7761 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7762 + return err 7763 + } 7764 + 7765 + // t.Description (string) (string) 7766 + if len("description") > 1000000 { 7767 + return xerrors.Errorf("Value in field \"description\" was too long") 7768 + } 7769 + 7770 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 7771 + return err 7772 + } 7773 + if _, err := cw.WriteString(string("description")); err != nil { 7774 + return err 7775 + } 7776 + 7777 + if len(t.Description) > 1000000 { 7778 + return xerrors.Errorf("Value in field t.Description was too long") 7779 + } 7780 + 7781 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 7782 + return err 7783 + } 7784 + if _, err := cw.WriteString(string(t.Description)); err != nil { 7785 + return err 7786 + } 7787 + return nil 7788 + } 7789 + 7790 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 7791 + *t = String{} 7792 + 7793 + cr := cbg.NewCborReader(r) 7794 + 7795 + maj, extra, err := cr.ReadHeader() 7796 + if err != nil { 7797 + return err 7798 + } 7799 + defer func() { 7800 + if err == io.EOF { 7801 + err = io.ErrUnexpectedEOF 7802 + } 7803 + }() 7804 + 7805 + if maj != cbg.MajMap { 7806 + return fmt.Errorf("cbor input should be of type map") 7807 + } 7808 + 7809 + if extra > cbg.MaxLength { 7810 + return fmt.Errorf("String: map struct too large (%d)", extra) 7811 + } 7812 + 7813 + n := extra 7814 + 7815 + nameBuf := make([]byte, 11) 7816 + for i := uint64(0); i < n; i++ { 7817 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7818 + if err != nil { 7819 + return err 7820 + } 7821 + 7822 + if !ok { 7823 + // Field doesn't exist on this type, so ignore it 7824 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7825 + return err 7826 + } 7827 + continue 7828 + } 7829 + 7830 + switch string(nameBuf[:nameLen]) { 7831 + // t.LexiconTypeID (string) (string) 7832 + case "$type": 7833 + 7834 + { 7835 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7836 + if err != nil { 7837 + return err 7838 + } 7839 + 7840 + t.LexiconTypeID = string(sval) 7841 + } 7842 + // t.Contents (string) (string) 7843 + case "contents": 7844 + 7845 + { 7846 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7847 + if err != nil { 7848 + return err 7849 + } 7850 + 7851 + t.Contents = string(sval) 7852 + } 7853 + // t.Filename (string) (string) 7854 + case "filename": 7855 + 7856 + { 7857 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7858 + if err != nil { 7859 + return err 7860 + } 7861 + 7862 + t.Filename = string(sval) 7863 + } 7864 + // t.CreatedAt (string) (string) 7865 + case "createdAt": 7866 + 7867 + { 7868 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7869 + if err != nil { 7870 + return err 7871 + } 7872 + 7873 + t.CreatedAt = string(sval) 7874 + } 7875 + // t.Description (string) (string) 7876 + case "description": 7877 + 7878 + { 7879 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7880 + if err != nil { 7881 + return err 7882 + } 7883 + 7884 + t.Description = string(sval) 7885 + } 7886 + 7887 + default: 7888 + // Field doesn't exist on this type, so ignore it 7889 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7890 + return err 7891 + } 7892 + } 7893 + } 7894 + 7895 + return nil 7896 + }
+19 -15
api/tangled/gitrefUpdate.go
··· 33 RepoName string `json:"repoName" cborgen:"repoName"` 34 } 35 36 - type GitRefUpdate_Meta struct { 37 - CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 - IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 39 - LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"` 40 } 41 42 - type GitRefUpdate_Meta_CommitCount struct { 43 - ByEmail []*GitRefUpdate_Meta_CommitCount_ByEmail_Elem `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"` 44 - } 45 - 46 - type GitRefUpdate_Meta_CommitCount_ByEmail_Elem struct { 47 Count int64 `json:"count" cborgen:"count"` 48 Email string `json:"email" cborgen:"email"` 49 } 50 51 - type GitRefUpdate_Meta_LangBreakdown struct { 52 - Inputs []*GitRefUpdate_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 53 - } 54 - 55 - // GitRefUpdate_Pair is a "pair" in the sh.tangled.git.refUpdate schema. 56 - type GitRefUpdate_Pair struct { 57 Lang string `json:"lang" cborgen:"lang"` 58 Size int64 `json:"size" cborgen:"size"` 59 }
··· 33 RepoName string `json:"repoName" cborgen:"repoName"` 34 } 35 36 + // GitRefUpdate_CommitCountBreakdown is a "commitCountBreakdown" in the sh.tangled.git.refUpdate schema. 37 + type GitRefUpdate_CommitCountBreakdown struct { 38 + ByEmail []*GitRefUpdate_IndividualEmailCommitCount `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"` 39 } 40 41 + // GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema. 42 + type GitRefUpdate_IndividualEmailCommitCount struct { 43 Count int64 `json:"count" cborgen:"count"` 44 Email string `json:"email" cborgen:"email"` 45 } 46 47 + // GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema. 48 + type GitRefUpdate_IndividualLanguageSize struct { 49 Lang string `json:"lang" cborgen:"lang"` 50 Size int64 `json:"size" cborgen:"size"` 51 } 52 + 53 + // GitRefUpdate_LangBreakdown is a "langBreakdown" in the sh.tangled.git.refUpdate schema. 54 + type GitRefUpdate_LangBreakdown struct { 55 + Inputs []*GitRefUpdate_IndividualLanguageSize `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 56 + } 57 + 58 + // GitRefUpdate_Meta is a "meta" in the sh.tangled.git.refUpdate schema. 59 + type GitRefUpdate_Meta struct { 60 + CommitCount *GitRefUpdate_CommitCountBreakdown `json:"commitCount" cborgen:"commitCount"` 61 + IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 62 + LangBreakdown *GitRefUpdate_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"` 63 + }
+1 -3
api/tangled/issuecomment.go
··· 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 Issue string `json:"issue" cborgen:"issue"` 25 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 27 }
··· 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 Body string `json:"body" cborgen:"body"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Issue string `json:"issue" cborgen:"issue"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 25 }
+53
api/tangled/knotlistKeys.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.listKeys 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotListKeysNSID = "sh.tangled.knot.listKeys" 15 + ) 16 + 17 + // KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call. 18 + type KnotListKeys_Output struct { 19 + // cursor: Pagination cursor for next page 20 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 21 + Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"` 22 + } 23 + 24 + // KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema. 25 + type KnotListKeys_PublicKey struct { 26 + // createdAt: Key upload timestamp 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // did: DID associated with the public key 29 + Did string `json:"did" cborgen:"did"` 30 + // key: Public key contents 31 + Key string `json:"key" cborgen:"key"` 32 + } 33 + 34 + // KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys". 35 + // 36 + // cursor: Pagination cursor 37 + // limit: Maximum number of keys to return 38 + func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) { 39 + var out KnotListKeys_Output 40 + 41 + params := map[string]interface{}{} 42 + if cursor != "" { 43 + params["cursor"] = cursor 44 + } 45 + if limit != 0 { 46 + params["limit"] = limit 47 + } 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+30
api/tangled/knotversion.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.version 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotVersionNSID = "sh.tangled.knot.version" 15 + ) 16 + 17 + // KnotVersion_Output is the output of a sh.tangled.knot.version call. 18 + type KnotVersion_Output struct { 19 + Version string `json:"version" cborgen:"version"` 20 + } 21 + 22 + // KnotVersion calls the XRPC method "sh.tangled.knot.version". 23 + func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) { 24 + var out KnotVersion_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+4 -7
api/tangled/pullcomment.go
··· 17 } // 18 // RECORDTYPE: RepoPullComment 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Pull string `json:"pull" cborgen:"pull"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 27 }
··· 17 } // 18 // RECORDTYPE: RepoPullComment 19 type RepoPullComment struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Pull string `json:"pull" cborgen:"pull"` 24 }
+41
api/tangled/repoarchive.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.archive 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoArchiveNSID = "sh.tangled.repo.archive" 16 + ) 17 + 18 + // RepoArchive calls the XRPC method "sh.tangled.repo.archive". 19 + // 20 + // format: Archive format 21 + // prefix: Prefix for files in the archive 22 + // ref: Git reference (branch, tag, or commit SHA) 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 + buf := new(bytes.Buffer) 26 + 27 + params := map[string]interface{}{} 28 + if format != "" { 29 + params["format"] = format 30 + } 31 + if prefix != "" { 32 + params["prefix"] = prefix 33 + } 34 + params["ref"] = ref 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil { 37 + return nil, err 38 + } 39 + 40 + return buf.Bytes(), nil 41 + }
+80
api/tangled/repoblob.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.blob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBlobNSID = "sh.tangled.repo.blob" 15 + ) 16 + 17 + // RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema. 18 + type RepoBlob_LastCommit struct { 19 + Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Commit hash 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Commit message 23 + Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 + // when: Commit timestamp 27 + When string `json:"when" cborgen:"when"` 28 + } 29 + 30 + // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 + type RepoBlob_Output struct { 32 + // content: File content (base64 encoded for binary files) 33 + Content string `json:"content" cborgen:"content"` 34 + // encoding: Content encoding 35 + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 + // isBinary: Whether the file is binary 37 + IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` 38 + LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 39 + // mimeType: MIME type of the file 40 + MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"` 41 + // path: The file path 42 + Path string `json:"path" cborgen:"path"` 43 + // ref: The git reference used 44 + Ref string `json:"ref" cborgen:"ref"` 45 + // size: File size in bytes 46 + Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + } 48 + 49 + // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. 50 + type RepoBlob_Signature struct { 51 + // email: Author email 52 + Email string `json:"email" cborgen:"email"` 53 + // name: Author name 54 + Name string `json:"name" cborgen:"name"` 55 + // when: Author timestamp 56 + When string `json:"when" cborgen:"when"` 57 + } 58 + 59 + // RepoBlob calls the XRPC method "sh.tangled.repo.blob". 60 + // 61 + // path: Path to the file within the repository 62 + // raw: Return raw file content instead of JSON response 63 + // ref: Git reference (branch, tag, or commit SHA) 64 + // repo: Repository identifier in format 'did:plc:.../repoName' 65 + func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) { 66 + var out RepoBlob_Output 67 + 68 + params := map[string]interface{}{} 69 + params["path"] = path 70 + if raw { 71 + params["raw"] = raw 72 + } 73 + params["ref"] = ref 74 + params["repo"] = repo 75 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+59
api/tangled/repobranch.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBranchNSID = "sh.tangled.repo.branch" 15 + ) 16 + 17 + // RepoBranch_Output is the output of a sh.tangled.repo.branch call. 18 + type RepoBranch_Output struct { 19 + Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on this branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // isDefault: Whether this is the default branch 23 + IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"` 24 + // message: Latest commit message 25 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 26 + // name: Branch name 27 + Name string `json:"name" cborgen:"name"` 28 + // shortHash: Short commit hash 29 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 30 + // when: Timestamp of latest commit 31 + When string `json:"when" cborgen:"when"` 32 + } 33 + 34 + // RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema. 35 + type RepoBranch_Signature struct { 36 + // email: Author email 37 + Email string `json:"email" cborgen:"email"` 38 + // name: Author name 39 + Name string `json:"name" cborgen:"name"` 40 + // when: Author timestamp 41 + When string `json:"when" cborgen:"when"` 42 + } 43 + 44 + // RepoBranch calls the XRPC method "sh.tangled.repo.branch". 45 + // 46 + // name: Branch name to get information for 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) { 49 + var out RepoBranch_Output 50 + 51 + params := map[string]interface{}{} 52 + params["name"] = name 53 + params["repo"] = repo 54 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+39
api/tangled/repobranches.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branches 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoBranchesNSID = "sh.tangled.repo.branches" 16 + ) 17 + 18 + // RepoBranches calls the XRPC method "sh.tangled.repo.branches". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of branches to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+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 + }
+35
api/tangled/repocompare.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.compare 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoCompareNSID = "sh.tangled.repo.compare" 16 + ) 17 + 18 + // RepoCompare calls the XRPC method "sh.tangled.repo.compare". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // rev1: First revision (commit, branch, or tag) 22 + // rev2: Second revision (commit, branch, or tag) 23 + func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + params["repo"] = repo 28 + params["rev1"] = rev1 29 + params["rev2"] = rev2 30 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil { 31 + return nil, err 32 + } 33 + 34 + return buf.Bytes(), nil 35 + }
+34
api/tangled/repocreate.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.create 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoCreateNSID = "sh.tangled.repo.create" 15 + ) 16 + 17 + // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 + type RepoCreate_Input struct { 19 + // defaultBranch: Default branch to push to 20 + DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 + // rkey: Rkey of the repository record 22 + Rkey string `json:"rkey" cborgen:"rkey"` 23 + // source: A source URL to clone from, populate this when forking or importing a repository. 24 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + } 26 + 27 + // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 + func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+34
api/tangled/repodelete.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.delete 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteNSID = "sh.tangled.repo.delete" 15 + ) 16 + 17 + // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. 18 + type RepoDelete_Input struct { 19 + // did: DID of the repository owner 20 + Did string `json:"did" cborgen:"did"` 21 + // name: Name of the repository to delete 22 + Name string `json:"name" cborgen:"name"` 23 + // rkey: Rkey of the repository record 24 + Rkey string `json:"rkey" cborgen:"rkey"` 25 + } 26 + 27 + // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 28 + func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+33
api/tangled/repodiff.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.diff 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoDiffNSID = "sh.tangled.repo.diff" 16 + ) 17 + 18 + // RepoDiff calls the XRPC method "sh.tangled.repo.diff". 19 + // 20 + // ref: Git reference (branch, tag, or commit SHA) 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["ref"] = ref 27 + params["repo"] = repo 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+45
api/tangled/repoforkStatus.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkStatus 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 + ) 16 + 17 + // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 + type RepoForkStatus_Input struct { 19 + // branch: Branch to check status for 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // hiddenRef: Hidden ref to use for comparison 24 + HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 + // name: Name of the forked repository 26 + Name string `json:"name" cborgen:"name"` 27 + // source: Source repository URL 28 + Source string `json:"source" cborgen:"source"` 29 + } 30 + 31 + // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 + type RepoForkStatus_Output struct { 33 + // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 + Status int64 `json:"status" cborgen:"status"` 35 + } 36 + 37 + // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 + func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 + var out RepoForkStatus_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+36
api/tangled/repoforkSync.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkSync 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 + ) 16 + 17 + // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 + type RepoForkSync_Input struct { 19 + // branch: Branch to sync 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // name: Name of the forked repository 24 + Name string `json:"name" cborgen:"name"` 25 + // source: AT-URI of the source repository 26 + Source string `json:"source" cborgen:"source"` 27 + } 28 + 29 + // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 + func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 + return err 33 + } 34 + 35 + return nil 36 + }
+55
api/tangled/repogetDefaultBranch.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch" 15 + ) 16 + 17 + // RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call. 18 + type RepoGetDefaultBranch_Output struct { 19 + Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on default branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Latest commit message 23 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 + // name: Default branch name 25 + Name string `json:"name" cborgen:"name"` 26 + // shortHash: Short commit hash 27 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 28 + // when: Timestamp of latest commit 29 + When string `json:"when" cborgen:"when"` 30 + } 31 + 32 + // RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema. 33 + type RepoGetDefaultBranch_Signature struct { 34 + // email: Author email 35 + Email string `json:"email" cborgen:"email"` 36 + // name: Author name 37 + Name string `json:"name" cborgen:"name"` 38 + // when: Author timestamp 39 + When string `json:"when" cborgen:"when"` 40 + } 41 + 42 + // RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch". 43 + // 44 + // repo: Repository identifier in format 'did:plc:.../repoName' 45 + func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) { 46 + var out RepoGetDefaultBranch_Output 47 + 48 + params := map[string]interface{}{} 49 + params["repo"] = repo 50 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil { 51 + return nil, err 52 + } 53 + 54 + return &out, nil 55 + }
+45
api/tangled/repohiddenRef.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
-2
api/tangled/repoissue.go
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 - Owner string `json:"owner" cborgen:"owner"` 25 Repo string `json:"repo" cborgen:"repo"` 26 Title string `json:"title" cborgen:"title"` 27 }
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Repo string `json:"repo" cborgen:"repo"` 24 Title string `json:"title" cborgen:"title"` 25 }
+61
api/tangled/repolanguages.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.languages 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoLanguagesNSID = "sh.tangled.repo.languages" 15 + ) 16 + 17 + // RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema. 18 + type RepoLanguages_Language struct { 19 + // color: Hex color code for this language 20 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 + // extensions: File extensions associated with this language 22 + Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 + // fileCount: Number of files in this language 24 + FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 + // name: Programming language name 26 + Name string `json:"name" cborgen:"name"` 27 + // percentage: Percentage of total codebase (0-100) 28 + Percentage int64 `json:"percentage" cborgen:"percentage"` 29 + // size: Total size of files in this language (bytes) 30 + Size int64 `json:"size" cborgen:"size"` 31 + } 32 + 33 + // RepoLanguages_Output is the output of a sh.tangled.repo.languages call. 34 + type RepoLanguages_Output struct { 35 + Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + // totalFiles: Total number of files analyzed 39 + TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 + // totalSize: Total size of all analyzed files in bytes 41 + TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 + } 43 + 44 + // RepoLanguages calls the XRPC method "sh.tangled.repo.languages". 45 + // 46 + // ref: Git reference (branch, tag, or commit SHA) 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) { 49 + var out RepoLanguages_Output 50 + 51 + params := map[string]interface{}{} 52 + if ref != "" { 53 + params["ref"] = ref 54 + } 55 + params["repo"] = repo 56 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil { 57 + return nil, err 58 + } 59 + 60 + return &out, nil 61 + }
+45
api/tangled/repolog.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.log 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoLogNSID = "sh.tangled.repo.log" 16 + ) 17 + 18 + // RepoLog calls the XRPC method "sh.tangled.repo.log". 19 + // 20 + // cursor: Pagination cursor (commit SHA) 21 + // limit: Maximum number of commits to return 22 + // path: Path to filter commits by 23 + // ref: Git reference (branch, tag, or commit SHA) 24 + // repo: Repository identifier in format 'did:plc:.../repoName' 25 + func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) { 26 + buf := new(bytes.Buffer) 27 + 28 + params := map[string]interface{}{} 29 + if cursor != "" { 30 + params["cursor"] = cursor 31 + } 32 + if limit != 0 { 33 + params["limit"] = limit 34 + } 35 + if path != "" { 36 + params["path"] = path 37 + } 38 + params["ref"] = ref 39 + params["repo"] = repo 40 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil { 41 + return nil, err 42 + } 43 + 44 + return buf.Bytes(), nil 45 + }
+44
api/tangled/repomerge.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.merge 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeNSID = "sh.tangled.repo.merge" 15 + ) 16 + 17 + // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 + type RepoMerge_Input struct { 19 + // authorEmail: Author email for the merge commit 20 + AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 + // authorName: Author name for the merge commit 22 + AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 + // branch: Target branch to merge into 24 + Branch string `json:"branch" cborgen:"branch"` 25 + // commitBody: Additional commit message body 26 + CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 + // commitMessage: Merge commit message 28 + CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch content to merge 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 + func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 + return err 41 + } 42 + 43 + return nil 44 + }
+57
api/tangled/repomergeCheck.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 + ) 16 + 17 + // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 + type RepoMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 + type RepoMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch or pull request to check for merge conflicts 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 + type RepoMergeCheck_Output struct { 39 + // conflicts: List of files with merge conflicts 40 + Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 + // error: Error message if check failed 42 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 + // is_conflicted: Whether the merge has conflicts 44 + Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 + // message: Additional message about the merge check 46 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 + } 48 + 49 + // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 + func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 + var out RepoMergeCheck_Output 52 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + }
+7 -3
api/tangled/repopull.go
··· 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Patch string `json:"patch" cborgen:"patch"` 24 - PullId int64 `json:"pullId" cborgen:"pullId"` 25 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 26 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 27 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 28 Title string `json:"title" cborgen:"title"` 29 } 30 ··· 34 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 35 Sha string `json:"sha" cborgen:"sha"` 36 }
··· 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Patch string `json:"patch" cborgen:"patch"` 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 + Target *RepoPull_Target `json:"target" cborgen:"target"` 26 Title string `json:"title" cborgen:"title"` 27 } 28 ··· 32 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 33 Sha string `json:"sha" cborgen:"sha"` 34 } 35 + 36 + // RepoPull_Target is a "target" in the sh.tangled.repo.pull schema. 37 + type RepoPull_Target struct { 38 + Branch string `json:"branch" cborgen:"branch"` 39 + Repo string `json:"repo" cborgen:"repo"` 40 + }
+39
api/tangled/repotags.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+72
api/tangled/repotree.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tree 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoTreeNSID = "sh.tangled.repo.tree" 15 + ) 16 + 17 + // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 + type RepoTree_LastCommit struct { 19 + // hash: Commit hash 20 + Hash string `json:"hash" cborgen:"hash"` 21 + // message: Commit message 22 + Message string `json:"message" cborgen:"message"` 23 + // when: Commit timestamp 24 + When string `json:"when" cborgen:"when"` 25 + } 26 + 27 + // RepoTree_Output is the output of a sh.tangled.repo.tree call. 28 + type RepoTree_Output struct { 29 + // dotdot: Parent directory path 30 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 + // parent: The parent path in the tree 33 + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // ref: The git reference used 35 + Ref string `json:"ref" cborgen:"ref"` 36 + } 37 + 38 + // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 39 + type RepoTree_TreeEntry struct { 40 + // is_file: Whether this entry is a file 41 + Is_file bool `json:"is_file" cborgen:"is_file"` 42 + // is_subtree: Whether this entry is a directory/subtree 43 + Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 44 + Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 45 + // mode: File mode 46 + Mode string `json:"mode" cborgen:"mode"` 47 + // name: Relative file or directory name 48 + Name string `json:"name" cborgen:"name"` 49 + // size: File size in bytes 50 + Size int64 `json:"size" cborgen:"size"` 51 + } 52 + 53 + // RepoTree calls the XRPC method "sh.tangled.repo.tree". 54 + // 55 + // path: Path within the repository tree 56 + // ref: Git reference (branch, tag, or commit SHA) 57 + // repo: Repository identifier in format 'did:plc:.../repoName' 58 + func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) { 59 + var out RepoTree_Output 60 + 61 + params := map[string]interface{}{} 62 + if path != "" { 63 + params["path"] = path 64 + } 65 + params["ref"] = ref 66 + params["repo"] = repo 67 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil { 68 + return nil, err 69 + } 70 + 71 + return &out, nil 72 + }
+22
api/tangled/tangledknot.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + KnotNSID = "sh.tangled.knot" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.knot", &Knot{}) 17 + } // 18 + // RECORDTYPE: Knot 19 + type Knot struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+30
api/tangled/tangledowner.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.owner 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + OwnerNSID = "sh.tangled.owner" 15 + ) 16 + 17 + // Owner_Output is the output of a sh.tangled.owner call. 18 + type Owner_Output struct { 19 + Owner string `json:"owner" cborgen:"owner"` 20 + } 21 + 22 + // Owner calls the XRPC method "sh.tangled.owner". 23 + func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) { 24 + var out Owner_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+4 -18
api/tangled/tangledpipeline.go
··· 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 } 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 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 39 type Pipeline_ManualTriggerData struct { 40 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 61 Ref string `json:"ref" cborgen:"ref"` 62 } 63 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 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 72 type Pipeline_TriggerMetadata struct { 73 Kind string `json:"kind" cborgen:"kind"` ··· 87 88 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 89 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"` 95 }
··· 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 } 31 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 33 type Pipeline_ManualTriggerData struct { 34 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 55 Ref string `json:"ref" cborgen:"ref"` 56 } 57 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 59 type Pipeline_TriggerMetadata struct { 60 Kind string `json:"kind" cborgen:"kind"` ··· 74 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 76 type Pipeline_Workflow struct { 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"` 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 PkceVerifier string 32 DpopAuthserverNonce string 33 DpopPrivateJwk string 34 } 35 36 type SessionStore struct {
··· 31 PkceVerifier string 32 DpopAuthserverNonce string 33 DpopPrivateJwk string 34 + ReturnUrl string 35 } 36 37 type SessionStore struct {
+24 -5
appview/config/config.go
··· 10 ) 11 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 18 } 19 20 type OAuthConfig struct { ··· 59 DB int `env:"DB, default=0"` 60 } 61 62 func (cfg RedisConfig) ToURL() string { 63 u := &url.URL{ 64 Scheme: "redis", ··· 84 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 87 } 88 89 func LoadConfig(ctx context.Context) (*Config, error) {
··· 10 ) 11 12 type CoreConfig struct { 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default knot and spindle 21 + AppPassword string `env:"APP_PASSWORD"` 22 + 23 + // uhhhh this is because knot1 is under icy's did 24 + TmpAltAppPassword string `env:"ALT_APP_PASSWORD"` 25 } 26 27 type OAuthConfig struct { ··· 66 DB int `env:"DB, default=0"` 67 } 68 69 + type PdsConfig struct { 70 + Host string `env:"HOST, default=https://tngl.sh"` 71 + AdminSecret string `env:"ADMIN_SECRET"` 72 + } 73 + 74 + type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + } 78 + 79 func (cfg RedisConfig) ToURL() string { 80 u := &url.URL{ 81 Scheme: "redis", ··· 101 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 102 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 103 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 104 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 105 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 106 } 107 108 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 + }
+318 -24
appview/db/db.go
··· 27 } 28 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 31 if err != nil { 32 return nil, err 33 } 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; 43 44 create table if not exists registrations ( 45 id integer primary key autoincrement, 46 domain text not null unique, ··· 436 unique(repo_at, ref, language) 437 ); 438 439 create table if not exists migrations ( 440 id integer primary key autoincrement, 441 name text unique 442 ); 443 `) 444 if err != nil { 445 return nil, err 446 } 447 448 // run migrations 449 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 450 tx.Exec(` 451 alter table repos add column description text check (length(description) <= 200); 452 `) 453 return nil 454 }) 455 456 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 457 // add unconstrained column 458 _, err := tx.Exec(` 459 alter table public_keys ··· 476 return nil 477 }) 478 479 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 480 _, err := tx.Exec(` 481 alter table comments drop column comment_at; 482 alter table comments add column rkey text; ··· 484 return err 485 }) 486 487 - runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 488 _, err := tx.Exec(` 489 alter table comments add column deleted text; -- timestamp 490 alter table comments add column edited text; -- timestamp ··· 492 return err 493 }) 494 495 - runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 496 _, err := tx.Exec(` 497 alter table pulls add column source_branch text; 498 alter table pulls add column source_repo_at text; ··· 501 return err 502 }) 503 504 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 505 _, err := tx.Exec(` 506 alter table repos add column source text; 507 `) ··· 512 // NOTE: this cannot be done in a transaction, so it is run outside [0] 513 // 514 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 515 - db.Exec("pragma foreign_keys = off;") 516 - runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 517 _, err := tx.Exec(` 518 create table pulls_new ( 519 -- identifiers ··· 568 `) 569 return err 570 }) 571 - db.Exec("pragma foreign_keys = on;") 572 573 // run migrations 574 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 575 tx.Exec(` 576 alter table repos add column spindle text; 577 `) 578 return nil 579 }) 580 581 return &DB{db}, nil 582 } 583 584 type migrationFn = func(*sql.Tx) error 585 586 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 587 - tx, err := d.Begin() 588 if err != nil { 589 return err 590 } ··· 624 return nil 625 } 626 627 type filter struct { 628 key string 629 arg any ··· 651 kind := rv.Kind() 652 653 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 654 - if kind == reflect.Slice || kind == reflect.Array { 655 if rv.Len() == 0 { 656 // always false 657 return "1 = 0" ··· 671 func (f filter) Arg() []any { 672 rv := reflect.ValueOf(f.arg) 673 kind := rv.Kind() 674 - if kind == reflect.Slice || kind == reflect.Array { 675 if rv.Len() == 0 { 676 return nil 677 }
··· 27 } 28 29 func Make(dbPath string) (*DB, error) { 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) 46 + if err != nil { 47 + return nil, err 48 + } 49 + defer conn.Close() 50 + 51 + _, err = conn.ExecContext(ctx, ` 52 create table if not exists registrations ( 53 id integer primary key autoincrement, 54 domain text not null unique, ··· 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 + 469 create table if not exists migrations ( 470 id integer primary key autoincrement, 471 name text unique 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); 477 `) 478 if err != nil { 479 return nil, err 480 } 481 482 // run migrations 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 484 tx.Exec(` 485 alter table repos add column description text check (length(description) <= 200); 486 `) 487 return nil 488 }) 489 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 491 // add unconstrained column 492 _, err := tx.Exec(` 493 alter table public_keys ··· 510 return nil 511 }) 512 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 514 _, err := tx.Exec(` 515 alter table comments drop column comment_at; 516 alter table comments add column rkey text; ··· 518 return err 519 }) 520 521 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 522 _, err := tx.Exec(` 523 alter table comments add column deleted text; -- timestamp 524 alter table comments add column edited text; -- timestamp ··· 526 return err 527 }) 528 529 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 530 _, err := tx.Exec(` 531 alter table pulls add column source_branch text; 532 alter table pulls add column source_repo_at text; ··· 535 return err 536 }) 537 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 539 _, err := tx.Exec(` 540 alter table repos add column source text; 541 `) ··· 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 547 // 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 549 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 550 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 551 _, err := tx.Exec(` 552 create table pulls_new ( 553 -- identifiers ··· 602 `) 603 return err 604 }) 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 606 607 // run migrations 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 609 tx.Exec(` 610 alter table repos add column spindle text; 611 `) 612 return nil 613 }) 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 640 + // recreate and add rkey + created columns with default constraint 641 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 642 + // create new table 643 + // - repo_at instead of repo integer 644 + // - rkey field 645 + // - created field 646 + _, err := tx.Exec(` 647 + create table collaborators_new ( 648 + -- identifiers for the record 649 + id integer primary key autoincrement, 650 + did text not null, 651 + rkey text, 652 + 653 + -- content 654 + subject_did text not null, 655 + repo_at text not null, 656 + 657 + -- meta 658 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 659 + 660 + -- constraints 661 + foreign key (repo_at) references repos(at_uri) on delete cascade 662 + ) 663 + `) 664 + if err != nil { 665 + return err 666 + } 667 + 668 + // copy data 669 + _, err = tx.Exec(` 670 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 671 + select 672 + c.id, 673 + r.did, 674 + '', 675 + c.did, 676 + r.at_uri 677 + from collaborators c 678 + join repos r on c.repo = r.id 679 + `) 680 + if err != nil { 681 + return err 682 + } 683 + 684 + // drop old table 685 + _, err = tx.Exec(`drop table collaborators`) 686 + if err != nil { 687 + return err 688 + } 689 + 690 + // rename new table 691 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 692 + return err 693 + }) 694 + 695 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 696 + _, err := tx.Exec(` 697 + alter table issues add column rkey text not null default ''; 698 + 699 + -- get last url section from issue_at and save to rkey column 700 + update issues 701 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 702 + `) 703 + return err 704 + }) 705 + 706 + // repurpose the read-only column to "needs-upgrade" 707 + runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 708 + _, err := tx.Exec(` 709 + alter table registrations rename column read_only to needs_upgrade; 710 + `) 711 + return err 712 + }) 713 + 714 + // require all knots to upgrade after the release of total xrpc 715 + runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 716 + _, err := tx.Exec(` 717 + update registrations set needs_upgrade = 1; 718 + `) 719 + return err 720 + }) 721 + 722 + // require all knots to upgrade after the release of total xrpc 723 + runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 724 + _, err := tx.Exec(` 725 + alter table spindles add column needs_upgrade integer not null default 0; 726 + `) 727 + if err != nil { 728 + return err 729 + } 730 + 731 + _, err = tx.Exec(` 732 + update spindles set needs_upgrade = 1; 733 + `) 734 + return err 735 + }) 736 + 737 + // remove issue_at from issues and replace with generated column 738 + // 739 + // this requires a full table recreation because stored columns 740 + // cannot be added via alter 741 + // 742 + // couple other changes: 743 + // - columns renamed to be more consistent 744 + // - adds edited and deleted fields 745 + // 746 + // disable foreign-keys for the next migration 747 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 748 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 749 + _, err := tx.Exec(` 750 + create table if not exists issues_new ( 751 + -- identifiers 752 + id integer primary key autoincrement, 753 + did text not null, 754 + rkey text not null, 755 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 756 + 757 + -- at identifiers 758 + repo_at text not null, 759 + 760 + -- content 761 + issue_id integer not null, 762 + title text not null, 763 + body text not null, 764 + open integer not null default 1, 765 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 766 + edited text, -- timestamp 767 + deleted text, -- timestamp 768 + 769 + unique(did, rkey), 770 + unique(repo_at, issue_id), 771 + unique(at_uri), 772 + foreign key (repo_at) references repos(at_uri) on delete cascade 773 + ); 774 + `) 775 + if err != nil { 776 + return err 777 + } 778 + 779 + // transfer data 780 + _, err = tx.Exec(` 781 + insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 782 + select 783 + i.id, 784 + i.owner_did, 785 + i.rkey, 786 + i.repo_at, 787 + i.issue_id, 788 + i.title, 789 + i.body, 790 + i.open, 791 + i.created 792 + from issues i; 793 + `) 794 + if err != nil { 795 + return err 796 + } 797 + 798 + // drop old table 799 + _, err = tx.Exec(`drop table issues`) 800 + if err != nil { 801 + return err 802 + } 803 + 804 + // rename new table 805 + _, err = tx.Exec(`alter table issues_new rename to issues`) 806 + return err 807 + }) 808 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 809 + 810 + // - renames the comments table to 'issue_comments' 811 + // - rework issue comments to update constraints: 812 + // * unique(did, rkey) 813 + // * remove comment-id and just use the global ID 814 + // * foreign key (repo_at, issue_id) 815 + // - new columns 816 + // * column "reply_to" which can be any other comment 817 + // * column "at-uri" which is a generated column 818 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 819 + _, err := tx.Exec(` 820 + create table if not exists issue_comments ( 821 + -- identifiers 822 + id integer primary key autoincrement, 823 + did text not null, 824 + rkey text, 825 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 826 + 827 + -- at identifiers 828 + issue_at text not null, 829 + reply_to text, -- at_uri of parent comment 830 + 831 + -- content 832 + body text not null, 833 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 834 + edited text, 835 + deleted text, 836 + 837 + -- constraints 838 + unique(did, rkey), 839 + unique(at_uri), 840 + foreign key (issue_at) references issues(at_uri) on delete cascade 841 + ); 842 + `) 843 + if err != nil { 844 + return err 845 + } 846 + 847 + // transfer data 848 + _, err = tx.Exec(` 849 + insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 850 + select 851 + c.id, 852 + c.owner_did, 853 + c.rkey, 854 + i.at_uri, -- get at_uri from issues table 855 + c.body, 856 + c.created, 857 + c.edited, 858 + c.deleted 859 + from comments c 860 + join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 861 + `) 862 + if err != nil { 863 + return err 864 + } 865 + 866 + // drop old table 867 + _, err = tx.Exec(`drop table comments`) 868 + return err 869 + }) 870 + 871 return &DB{db}, nil 872 } 873 874 type migrationFn = func(*sql.Tx) error 875 876 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 877 + tx, err := c.BeginTx(context.Background(), nil) 878 if err != nil { 879 return err 880 } ··· 914 return nil 915 } 916 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 919 + } 920 + 921 type filter struct { 922 key string 923 arg any ··· 945 kind := rv.Kind() 946 947 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 948 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 949 if rv.Len() == 0 { 950 // always false 951 return "1 = 0" ··· 965 func (f filter) Arg() []any { 966 rv := reflect.ValueOf(f.arg) 967 kind := rv.Kind() 968 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 969 if rv.Len() == 0 { 970 return nil 971 }
+16 -2
appview/db/email.go
··· 103 query := ` 104 select email, did 105 from emails 106 - where 107 - verified = ? 108 and email in (` + strings.Join(placeholders, ",") + `) 109 ` 110 ··· 153 ` 154 var count int 155 err := e.QueryRow(query, did, email).Scan(&count) 156 if err != nil { 157 return false, err 158 }
··· 103 query := ` 104 select email, did 105 from emails 106 + where 107 + verified = ? 108 and email in (` + strings.Join(placeholders, ",") + `) 109 ` 110 ··· 153 ` 154 var count int 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 161 + 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 170 if err != nil { 171 return false, err 172 }
+145 -42
appview/db/follow.go
··· 1 package db 2 3 import ( 4 "log" 5 "time" 6 ) 7 ··· 53 return err 54 } 55 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 57 - followers, following := 0, 0 58 err := e.QueryRow( 59 - `SELECT 60 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 61 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 62 FROM follows;`, did, did).Scan(&followers, &following) 63 if err != nil { 64 - return 0, 0, err 65 } 66 - return followers, following, nil 67 } 68 69 - type FollowStatus int 70 71 - const ( 72 - IsNotFollowing FollowStatus = iota 73 - IsFollowing 74 - IsSelf 75 - ) 76 77 - func (s FollowStatus) String() string { 78 - switch s { 79 - case IsNotFollowing: 80 - return "IsNotFollowing" 81 - case IsFollowing: 82 - return "IsFollowing" 83 - case IsSelf: 84 - return "IsSelf" 85 - default: 86 - return "IsNotFollowing" 87 } 88 - } 89 90 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 - if userDid == subjectDid { 92 - return IsSelf 93 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 - return IsNotFollowing 95 - } else { 96 - return IsFollowing 97 } 98 } 99 100 - func GetAllFollows(e Execer, limit int) ([]Follow, error) { 101 var follows []Follow 102 103 - rows, err := e.Query(` 104 - select user_did, subject_did, followed_at, rkey 105 from follows 106 order by followed_at desc 107 - limit ?`, limit, 108 - ) 109 if err != nil { 110 return nil, err 111 } 112 - defer rows.Close() 113 - 114 for rows.Next() { 115 var follow Follow 116 var followedAt string 117 - if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 118 return nil, err 119 } 120 - 121 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 122 if err != nil { 123 log.Println("unable to determine followed at time") ··· 125 } else { 126 follow.FollowedAt = followedAtTime 127 } 128 - 129 follows = append(follows, follow) 130 } 131 132 - if err := rows.Err(); err != nil { 133 - return nil, err 134 } 135 136 - return follows, nil 137 }
··· 1 package db 2 3 import ( 4 + "fmt" 5 "log" 6 + "strings" 7 "time" 8 ) 9 ··· 55 return err 56 } 57 58 + type FollowStats struct { 59 + Followers int64 60 + Following int64 61 + } 62 + 63 + func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 + var followers, following int64 65 err := e.QueryRow( 66 + `SELECT 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 FROM follows;`, did, did).Scan(&followers, &following) 70 if err != nil { 71 + return FollowStats{}, err 72 } 73 + return FollowStats{ 74 + Followers: followers, 75 + Following: following, 76 + }, nil 77 } 78 79 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 + if len(dids) == 0 { 81 + return nil, nil 82 + } 83 84 + placeholders := make([]string, len(dids)) 85 + for i := range placeholders { 86 + placeholders[i] = "?" 87 + } 88 + placeholderStr := strings.Join(placeholders, ",") 89 90 + args := make([]any, len(dids)*2) 91 + for i, did := range dids { 92 + args[i] = did 93 + args[i+len(dids)] = did 94 } 95 + 96 + query := fmt.Sprintf(` 97 + select 98 + coalesce(f.did, g.did) as did, 99 + coalesce(f.followers, 0) as followers, 100 + coalesce(g.following, 0) as following 101 + from ( 102 + select subject_did as did, count(*) as followers 103 + from follows 104 + where subject_did in (%s) 105 + group by subject_did 106 + ) f 107 + full outer join ( 108 + select user_did as did, count(*) as following 109 + from follows 110 + where user_did in (%s) 111 + group by user_did 112 + ) g on f.did = g.did`, 113 + placeholderStr, placeholderStr) 114 + 115 + result := make(map[string]FollowStats) 116 + 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 120 + } 121 + defer rows.Close() 122 + 123 + for rows.Next() { 124 + var did string 125 + var followers, following int64 126 + if err := rows.Scan(&did, &followers, &following); err != nil { 127 + return nil, err 128 + } 129 + result[did] = FollowStats{ 130 + Followers: followers, 131 + Following: following, 132 + } 133 + } 134 135 + for _, did := range dids { 136 + if _, exists := result[did]; !exists { 137 + result[did] = FollowStats{ 138 + Followers: 0, 139 + Following: 0, 140 + } 141 + } 142 } 143 + 144 + return result, nil 145 } 146 147 + func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 var follows []Follow 149 150 + var conditions []string 151 + var args []any 152 + for _, filter := range filters { 153 + conditions = append(conditions, filter.Condition()) 154 + args = append(args, filter.Arg()...) 155 + } 156 + 157 + whereClause := "" 158 + if conditions != nil { 159 + whereClause = " where " + strings.Join(conditions, " and ") 160 + } 161 + limitClause := "" 162 + if limit > 0 { 163 + limitClause = " limit ?" 164 + args = append(args, limit) 165 + } 166 + 167 + query := fmt.Sprintf( 168 + `select user_did, subject_did, followed_at, rkey 169 from follows 170 + %s 171 order by followed_at desc 172 + %s 173 + `, whereClause, limitClause) 174 + 175 + rows, err := e.Query(query, args...) 176 if err != nil { 177 return nil, err 178 } 179 for rows.Next() { 180 var follow Follow 181 var followedAt string 182 + err := rows.Scan( 183 + &follow.UserDid, 184 + &follow.SubjectDid, 185 + &followedAt, 186 + &follow.Rkey, 187 + ) 188 + if err != nil { 189 return nil, err 190 } 191 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 192 if err != nil { 193 log.Println("unable to determine followed at time") ··· 195 } else { 196 follow.FollowedAt = followedAtTime 197 } 198 follows = append(follows, follow) 199 } 200 + return follows, nil 201 + } 202 + 203 + func GetFollowers(e Execer, did string) ([]Follow, error) { 204 + return GetFollows(e, 0, FilterEq("subject_did", did)) 205 + } 206 207 + func GetFollowing(e Execer, did string) ([]Follow, error) { 208 + return GetFollows(e, 0, FilterEq("user_did", did)) 209 + } 210 + 211 + type FollowStatus int 212 + 213 + const ( 214 + IsNotFollowing FollowStatus = iota 215 + IsFollowing 216 + IsSelf 217 + ) 218 + 219 + func (s FollowStatus) String() string { 220 + switch s { 221 + case IsNotFollowing: 222 + return "IsNotFollowing" 223 + case IsFollowing: 224 + return "IsFollowing" 225 + case IsSelf: 226 + return "IsSelf" 227 + default: 228 + return "IsNotFollowing" 229 } 230 + } 231 232 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 + if userDid == subjectDid { 234 + return IsSelf 235 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 + return IsNotFollowing 237 + } else { 238 + return IsFollowing 239 + } 240 }
+459 -311
appview/db/issues.go
··· 2 3 import ( 4 "database/sql" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "tangled.sh/tangled.sh/core/appview/pagination" 9 ) 10 11 type Issue struct { 12 - ID int64 13 - RepoAt syntax.ATURI 14 - OwnerDid string 15 - IssueId int 16 - IssueAt string 17 - Created time.Time 18 - Title string 19 - Body string 20 - Open bool 21 22 // optionally, populate this when querying for reverse mappings 23 // like comment counts, parent repo etc. 24 - Metadata *IssueMetadata 25 } 26 27 - type IssueMetadata struct { 28 - CommentCount int 29 - Repo *Repo 30 - // labels, assignee etc. 31 } 32 33 - type Comment struct { 34 - OwnerDid string 35 - RepoAt syntax.ATURI 36 - Rkey string 37 - Issue int 38 - CommentId int 39 - Body string 40 - Created *time.Time 41 - Deleted *time.Time 42 - Edited *time.Time 43 } 44 45 - func NewIssue(tx *sql.Tx, issue *Issue) error { 46 - defer tx.Rollback() 47 48 - _, err := tx.Exec(` 49 - insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 50 - values (?, 1) 51 - `, issue.RepoAt) 52 - if err != nil { 53 - return err 54 } 55 56 - var nextId int 57 - err = tx.QueryRow(` 58 - update repo_issue_seqs 59 - set next_issue_id = next_issue_id + 1 60 - where repo_at = ? 61 - returning next_issue_id - 1 62 - `, issue.RepoAt).Scan(&nextId) 63 - if err != nil { 64 - return err 65 } 66 67 - issue.IssueId = nextId 68 69 - res, err := tx.Exec(` 70 - insert into issues (repo_at, owner_did, issue_id, title, body) 71 - values (?, ?, ?, ?, ?) 72 - `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 73 if err != nil { 74 - return err 75 } 76 77 - lastID, err := res.LastInsertId() 78 - if err != nil { 79 - return err 80 } 81 - issue.ID = lastID 82 83 - if err := tx.Commit(); err != nil { 84 - return err 85 } 86 87 - return nil 88 } 89 90 - func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error { 91 - _, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId) 92 - return err 93 } 94 95 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 96 - var issueAt string 97 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 98 - return issueAt, err 99 } 100 101 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 102 - var ownerDid string 103 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 104 - return ownerDid, err 105 } 106 107 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 108 - var issues []Issue 109 - openValue := 0 110 - if isOpen { 111 - openValue = 1 112 } 113 114 - rows, err := e.Query( 115 - ` 116 - with numbered_issue as ( 117 - select 118 - i.id, 119 - i.owner_did, 120 - i.issue_id, 121 - i.created, 122 - i.title, 123 - i.body, 124 - i.open, 125 - count(c.id) as comment_count, 126 - row_number() over (order by i.created desc) as row_num 127 - from 128 - issues i 129 - left join 130 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 131 - where 132 - i.repo_at = ? and i.open = ? 133 - group by 134 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 135 - ) 136 - select 137 - id, 138 - owner_did, 139 - issue_id, 140 - created, 141 - title, 142 - body, 143 - open, 144 - comment_count 145 - from 146 - numbered_issue 147 - where 148 - row_num between ? and ?`, 149 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 150 - if err != nil { 151 return nil, err 152 } 153 - defer rows.Close() 154 155 - for rows.Next() { 156 - var issue Issue 157 - var createdAt string 158 - var metadata IssueMetadata 159 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 - if err != nil { 161 - return nil, err 162 - } 163 164 - createdTime, err := time.Parse(time.RFC3339, createdAt) 165 - if err != nil { 166 - return nil, err 167 } 168 - issue.Created = createdTime 169 - issue.Metadata = &metadata 170 171 - issues = append(issues, issue) 172 } 173 174 - if err := rows.Err(); err != nil { 175 - return nil, err 176 } 177 178 - return issues, nil 179 } 180 181 - // timeframe here is directly passed into the sql query filter, and any 182 - // timeframe in the past should be negative; e.g.: "-3 months" 183 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 184 - var issues []Issue 185 186 - rows, err := e.Query( 187 - `select 188 - i.id, 189 - i.owner_did, 190 - i.repo_at, 191 - i.issue_id, 192 - i.created, 193 - i.title, 194 - i.body, 195 - i.open, 196 - r.did, 197 - r.name, 198 - r.knot, 199 - r.rkey, 200 - r.created 201 - from 202 - issues i 203 - join 204 - repos r on i.repo_at = r.at_uri 205 - where 206 - i.owner_did = ? and i.created >= date ('now', ?) 207 - order by 208 - i.created desc`, 209 - ownerDid, timeframe) 210 if err != nil { 211 - return nil, err 212 } 213 defer rows.Close() 214 215 for rows.Next() { 216 var issue Issue 217 - var issueCreatedAt, repoCreatedAt string 218 - var repo Repo 219 err := rows.Scan( 220 - &issue.ID, 221 - &issue.OwnerDid, 222 &issue.RepoAt, 223 &issue.IssueId, 224 - &issueCreatedAt, 225 &issue.Title, 226 &issue.Body, 227 &issue.Open, 228 - &repo.Did, 229 - &repo.Name, 230 - &repo.Knot, 231 - &repo.Rkey, 232 - &repoCreatedAt, 233 ) 234 if err != nil { 235 - return nil, err 236 } 237 238 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 239 - if err != nil { 240 - return nil, err 241 } 242 - issue.Created = issueCreatedTime 243 244 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 245 - if err != nil { 246 - return nil, err 247 } 248 - repo.Created = repoCreatedTime 249 250 - issue.Metadata = &IssueMetadata{ 251 - Repo: &repo, 252 } 253 254 - issues = append(issues, issue) 255 } 256 257 - if err := rows.Err(); err != nil { 258 - return nil, err 259 } 260 261 return issues, nil 262 } 263 264 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 265 - query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 row := e.QueryRow(query, repoAt, issueId) 267 268 var issue Issue 269 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 if err != nil { 272 return nil, err 273 } ··· 281 return &issue, nil 282 } 283 284 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 285 - query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 286 - row := e.QueryRow(query, repoAt, issueId) 287 - 288 - var issue Issue 289 - var createdAt string 290 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 291 if err != nil { 292 - return nil, nil, err 293 } 294 295 - createdTime, err := time.Parse(time.RFC3339, createdAt) 296 if err != nil { 297 - return nil, nil, err 298 } 299 - issue.Created = createdTime 300 301 - comments, err := GetComments(e, repoAt, issueId) 302 - if err != nil { 303 - return nil, nil, err 304 } 305 306 - return &issue, comments, nil 307 - } 308 309 - func NewIssueComment(e Execer, comment *Comment) error { 310 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 311 - _, err := e.Exec( 312 - query, 313 - comment.OwnerDid, 314 - comment.RepoAt, 315 - comment.Rkey, 316 - comment.Issue, 317 - comment.CommentId, 318 - comment.Body, 319 - ) 320 return err 321 } 322 323 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 324 - var comments []Comment 325 326 - rows, err := e.Query(` 327 select 328 - owner_did, 329 - issue_id, 330 - comment_id, 331 rkey, 332 body, 333 created, 334 edited, 335 deleted 336 from 337 - comments 338 - where 339 - repo_at = ? and issue_id = ? 340 - order by 341 - created asc`, 342 - repoAt, 343 - issueId, 344 - ) 345 - if err == sql.ErrNoRows { 346 - return []Comment{}, nil 347 - } 348 if err != nil { 349 return nil, err 350 } 351 - defer rows.Close() 352 353 for rows.Next() { 354 - var comment Comment 355 - var createdAt string 356 - var deletedAt, editedAt, rkey sql.NullString 357 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 358 if err != nil { 359 return nil, err 360 } 361 362 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 363 - if err != nil { 364 - return nil, err 365 } 366 - comment.Created = &createdAtTime 367 368 - if deletedAt.Valid { 369 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 370 - if err != nil { 371 - return nil, err 372 } 373 - comment.Deleted = &deletedTime 374 } 375 376 - if editedAt.Valid { 377 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 378 - if err != nil { 379 - return nil, err 380 } 381 - comment.Edited = &editedTime 382 } 383 384 - if rkey.Valid { 385 - comment.Rkey = rkey.String 386 } 387 388 comments = append(comments, comment) 389 } 390 391 - if err := rows.Err(); err != nil { 392 return nil, err 393 } 394 395 return comments, nil 396 } 397 398 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 399 - query := ` 400 - select 401 - owner_did, body, rkey, created, deleted, edited 402 - from 403 - comments where repo_at = ? and issue_id = ? and comment_id = ? 404 - ` 405 - row := e.QueryRow(query, repoAt, issueId, commentId) 406 - 407 - var comment Comment 408 - var createdAt string 409 - var deletedAt, editedAt, rkey sql.NullString 410 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 411 - if err != nil { 412 - return nil, err 413 } 414 415 - createdTime, err := time.Parse(time.RFC3339, createdAt) 416 - if err != nil { 417 - return nil, err 418 } 419 - comment.Created = &createdTime 420 421 - if deletedAt.Valid { 422 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 423 - if err != nil { 424 - return nil, err 425 - } 426 - comment.Deleted = &deletedTime 427 - } 428 429 - if editedAt.Valid { 430 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 431 - if err != nil { 432 - return nil, err 433 - } 434 - comment.Edited = &editedTime 435 } 436 437 - if rkey.Valid { 438 - comment.Rkey = rkey.String 439 } 440 441 - comment.RepoAt = repoAt 442 - comment.Issue = issueId 443 - comment.CommentId = commentId 444 - 445 - return &comment, nil 446 - } 447 - 448 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 449 - _, err := e.Exec( 450 - ` 451 - update comments 452 - set body = ?, 453 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 454 - where repo_at = ? and issue_id = ? and comment_id = ? 455 - `, newBody, repoAt, issueId, commentId) 456 return err 457 } 458 459 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 460 - _, err := e.Exec( 461 - ` 462 - update comments 463 - set body = "", 464 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 465 - where repo_at = ? and issue_id = ? and comment_id = ? 466 - `, repoAt, issueId, commentId) 467 - return err 468 - } 469 470 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 471 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 472 - return err 473 - } 474 475 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 476 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 477 return err 478 } 479
··· 2 3 import ( 4 "database/sql" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 + "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/appview/pagination" 15 ) 16 17 type Issue struct { 18 + Id int64 19 + Did string 20 + Rkey string 21 + RepoAt syntax.ATURI 22 + IssueId int 23 + Created time.Time 24 + Edited *time.Time 25 + Deleted *time.Time 26 + Title string 27 + Body string 28 + Open bool 29 30 // optionally, populate this when querying for reverse mappings 31 // like comment counts, parent repo etc. 32 + Comments []IssueComment 33 + Repo *Repo 34 } 35 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 38 + } 39 + 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 47 + } 48 + 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 } 55 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 59 } 60 61 + func (i *Issue) CommentList() []CommentListItem { 62 + // Create a map to quickly find comments by their aturi 63 + toplevel := make(map[string]*CommentListItem) 64 + var replies []*IssueComment 65 66 + // collect top level comments into the map 67 + for _, comment := range i.Comments { 68 + if comment.IsTopLevel() { 69 + toplevel[comment.AtUri().String()] = &CommentListItem{ 70 + Self: &comment, 71 + } 72 + } else { 73 + replies = append(replies, &comment) 74 + } 75 + } 76 + 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 82 + } 83 + 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 } 88 89 + // sort everything 90 + sortFunc := func(a, b *IssueComment) bool { 91 + return a.Created.Before(b.Created) 92 + } 93 + sort.Slice(listing, func(i, j int) bool { 94 + return sortFunc(listing[i].Self, listing[j].Self) 95 + }) 96 + for _, r := range listing { 97 + sort.Slice(r.Replies, func(i, j int) bool { 98 + return sortFunc(r.Replies[i], r.Replies[j]) 99 + }) 100 } 101 102 + return listing 103 + } 104 105 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 107 if err != nil { 108 + created = time.Now() 109 } 110 111 + body := "" 112 + if record.Body != nil { 113 + body = *record.Body 114 } 115 116 + return Issue{ 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 124 } 125 + } 126 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 137 } 138 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 141 } 142 143 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 + return tangled.RepoIssueComment{ 145 + Body: i.Body, 146 + Issue: i.IssueAt, 147 + CreatedAt: i.Created.Format(time.RFC3339), 148 + ReplyTo: i.ReplyTo, 149 + } 150 } 151 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 154 } 155 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 158 + if err != nil { 159 + created = time.Now() 160 } 161 162 + ownerDid := did 163 + 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 165 return nil, err 166 } 167 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 175 + } 176 177 + return &comment, nil 178 + } 179 + 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 182 + _, err := tx.Exec(` 183 + insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 184 + values (?, 1) 185 + `, issue.RepoAt) 186 + if err != nil { 187 + return err 188 + } 189 + 190 + issues, err := GetIssues( 191 + tx, 192 + FilterEq("did", issue.Did), 193 + FilterEq("rkey", issue.Rkey), 194 + ) 195 + switch { 196 + case err != nil: 197 + return err 198 + case len(issues) == 0: 199 + return createNewIssue(tx, issue) 200 + case len(issues) != 1: // should be unreachable 201 + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 202 + default: 203 + // if content is identical, do not edit 204 + existingIssue := issues[0] 205 + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 206 + return nil 207 } 208 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 212 } 213 + } 214 215 + func createNewIssue(tx *sql.Tx, issue *Issue) error { 216 + // get next issue_id 217 + var newIssueId int 218 + err := tx.QueryRow(` 219 + update repo_issue_seqs 220 + set next_issue_id = next_issue_id + 1 221 + where repo_at = ? 222 + returning next_issue_id - 1 223 + `, issue.RepoAt).Scan(&newIssueId) 224 + if err != nil { 225 + return err 226 } 227 228 + // insert new issue 229 + row := tx.QueryRow(` 230 + insert into issues (repo_at, did, rkey, issue_id, title, body) 231 + values (?, ?, ?, ?, ?, ?) 232 + returning rowid, issue_id 233 + `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 234 + 235 + return row.Scan(&issue.Id, &issue.IssueId) 236 } 237 238 + func updateIssue(tx *sql.Tx, issue *Issue) error { 239 + // update existing issue 240 + _, err := tx.Exec(` 241 + update issues 242 + set title = ?, body = ?, edited = ? 243 + where did = ? and rkey = ? 244 + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 245 + return err 246 + } 247 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 250 + 251 + var conditions []string 252 + var args []any 253 + 254 + for _, filter := range filters { 255 + conditions = append(conditions, filter.Condition()) 256 + args = append(args, filter.Arg()...) 257 + } 258 + 259 + whereClause := "" 260 + if conditions != nil { 261 + whereClause = " where " + strings.Join(conditions, " and ") 262 + } 263 + 264 + pLower := FilterGte("row_num", page.Offset+1) 265 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 266 + 267 + args = append(args, pLower.Arg()...) 268 + args = append(args, pUpper.Arg()...) 269 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 270 + 271 + query := fmt.Sprintf( 272 + ` 273 + select * from ( 274 + select 275 + id, 276 + did, 277 + rkey, 278 + repo_at, 279 + issue_id, 280 + title, 281 + body, 282 + open, 283 + created, 284 + edited, 285 + deleted, 286 + row_number() over (order by created desc) as row_num 287 + from 288 + issues 289 + %s 290 + ) ranked_issues 291 + %s 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 296 + 297 + rows, err := e.Query(query, args...) 298 if err != nil { 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 300 } 301 defer rows.Close() 302 303 for rows.Next() { 304 var issue Issue 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 308 err := rows.Scan( 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 312 &issue.RepoAt, 313 &issue.IssueId, 314 &issue.Title, 315 &issue.Body, 316 &issue.Open, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 321 ) 322 if err != nil { 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 324 } 325 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 328 } 329 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 334 + } 335 + 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 340 } 341 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 344 + } 345 + 346 + // collect reverse repos 347 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 348 + for _, issue := range issueMap { 349 + repoAts = append(repoAts, string(issue.RepoAt)) 350 + } 351 + 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 353 + if err != nil { 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 + } 356 + 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 361 + 362 + for issueAt, i := range issueMap { 363 + if r, ok := repoMap[string(i.RepoAt)]; ok { 364 + i.Repo = r 365 + } else { 366 + // do not show up the issue if the repo is deleted 367 + // TODO: foreign key where? 368 + delete(issueMap, issueAt) 369 } 370 + } 371 372 + // collect comments 373 + issueAts := slices.Collect(maps.Keys(issueMap)) 374 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 + if err != nil { 376 + return nil, fmt.Errorf("failed to query comments: %w", err) 377 } 378 379 + for i := range comments { 380 + issueAt := comments[i].IssueAt 381 + if issue, ok := issueMap[issueAt]; ok { 382 + issue.Comments = append(issue.Comments, comments[i]) 383 + } 384 + } 385 + 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 389 } 390 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 394 + 395 return issues, nil 396 + } 397 + 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 } 401 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 403 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 404 row := e.QueryRow(query, repoAt, issueId) 405 406 var issue Issue 407 var createdAt string 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 409 if err != nil { 410 return nil, err 411 } ··· 419 return &issue, nil 420 } 421 422 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 423 + result, err := e.Exec( 424 + `insert into issue_comments ( 425 + did, 426 + rkey, 427 + issue_at, 428 + body, 429 + reply_to, 430 + created, 431 + edited 432 + ) 433 + values (?, ?, ?, ?, ?, ?, null) 434 + on conflict(did, rkey) do update set 435 + issue_at = excluded.issue_at, 436 + body = excluded.body, 437 + edited = case 438 + when 439 + issue_comments.issue_at != excluded.issue_at 440 + or issue_comments.body != excluded.body 441 + or issue_comments.reply_to != excluded.reply_to 442 + then ? 443 + else issue_comments.edited 444 + end`, 445 + c.Did, 446 + c.Rkey, 447 + c.IssueAt, 448 + c.Body, 449 + c.ReplyTo, 450 + c.Created.Format(time.RFC3339), 451 + time.Now().Format(time.RFC3339), 452 + ) 453 if err != nil { 454 + return 0, err 455 } 456 457 + id, err := result.LastInsertId() 458 if err != nil { 459 + return 0, err 460 } 461 462 + return id, nil 463 + } 464 + 465 + func DeleteIssueComments(e Execer, filters ...filter) error { 466 + var conditions []string 467 + var args []any 468 + for _, filter := range filters { 469 + conditions = append(conditions, filter.Condition()) 470 + args = append(args, filter.Arg()...) 471 } 472 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 477 478 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 479 + 480 + _, err := e.Exec(query, args...) 481 return err 482 } 483 484 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 + var comments []IssueComment 486 + 487 + var conditions []string 488 + var args []any 489 + for _, filter := range filters { 490 + conditions = append(conditions, filter.Condition()) 491 + args = append(args, filter.Arg()...) 492 + } 493 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 498 + 499 + query := fmt.Sprintf(` 500 select 501 + id, 502 + did, 503 rkey, 504 + issue_at, 505 + reply_to, 506 body, 507 created, 508 edited, 509 deleted 510 from 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 516 if err != nil { 517 return nil, err 518 } 519 520 for rows.Next() { 521 + var comment IssueComment 522 + var created string 523 + var rkey, edited, deleted, replyTo sql.Null[string] 524 + err := rows.Scan( 525 + &comment.Id, 526 + &comment.Did, 527 + &rkey, 528 + &comment.IssueAt, 529 + &replyTo, 530 + &comment.Body, 531 + &created, 532 + &edited, 533 + &deleted, 534 + ) 535 if err != nil { 536 return nil, err 537 } 538 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 542 } 543 544 + if t, err := time.Parse(time.RFC3339, created); err == nil { 545 + comment.Created = t 546 + } 547 + 548 + if edited.Valid { 549 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 550 + comment.Edited = &t 551 } 552 } 553 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 557 } 558 } 559 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 562 } 563 564 comments = append(comments, comment) 565 } 566 567 + if err = rows.Err(); err != nil { 568 return nil, err 569 } 570 571 return comments, nil 572 } 573 574 + func DeleteIssues(e Execer, filters ...filter) error { 575 + var conditions []string 576 + var args []any 577 + for _, filter := range filters { 578 + conditions = append(conditions, filter.Condition()) 579 + args = append(args, filter.Arg()...) 580 } 581 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 585 } 586 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 591 592 + func CloseIssues(e Execer, filters ...filter) error { 593 + var conditions []string 594 + var args []any 595 + for _, filter := range filters { 596 + conditions = append(conditions, filter.Condition()) 597 + args = append(args, filter.Arg()...) 598 } 599 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 603 } 604 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 607 return err 608 } 609 610 + func ReopenIssues(e Execer, filters ...filter) error { 611 + var conditions []string 612 + var args []any 613 + for _, filter := range filters { 614 + conditions = append(conditions, filter.Condition()) 615 + args = append(args, filter.Arg()...) 616 + } 617 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 622 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 625 return err 626 } 627
-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;
···
+25 -12
appview/db/profile.go
··· 22 ByMonth []ByMonth 23 } 24 25 type ByMonth struct { 26 RepoEvents []RepoEvent 27 IssueEvents IssueEvents ··· 118 *items = append(*items, &pull) 119 } 120 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 122 if err != nil { 123 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 } ··· 137 *items = append(*items, &issue) 138 } 139 140 - repos, err := GetAllReposByDid(e, forDid) 141 if err != nil { 142 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 } ··· 348 return tx.Commit() 349 } 350 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 var conditions []string 353 var args []any 354 for _, filter := range filters { ··· 448 idxs[did] = idx + 1 449 } 450 451 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 457 } 458 459 func GetProfile(e Execer, did string) (*Profile, error) { ··· 540 query = `select count(id) from pulls where owner_did = ? and state = ?` 541 args = append(args, did, PullOpen) 542 case VanityStatOpenIssueCount: 543 - query = `select count(id) from issues where owner_did = ? and open = 1` 544 args = append(args, did) 545 case VanityStatClosedIssueCount: 546 - query = `select count(id) from issues where owner_did = ? and open = 0` 547 args = append(args, did) 548 case VanityStatRepositoryCount: 549 query = `select count(id) from repos where did = ?` ··· 577 } 578 579 // ensure all pinned repos are either own repos or collaborating repos 580 - repos, err := GetAllReposByDid(e, profile.Did) 581 if err != nil { 582 log.Printf("getting repos for %s: %s", profile.Did, err) 583 }
··· 22 ByMonth []ByMonth 23 } 24 25 + func (p *ProfileTimeline) IsEmpty() bool { 26 + if p == nil { 27 + return true 28 + } 29 + 30 + for _, m := range p.ByMonth { 31 + if !m.IsEmpty() { 32 + return false 33 + } 34 + } 35 + 36 + return true 37 + } 38 + 39 type ByMonth struct { 40 RepoEvents []RepoEvent 41 IssueEvents IssueEvents ··· 132 *items = append(*items, &pull) 133 } 134 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 140 if err != nil { 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 142 } ··· 155 *items = append(*items, &issue) 156 } 157 158 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 159 if err != nil { 160 return nil, fmt.Errorf("error getting all repos by did: %w", err) 161 } ··· 366 return tx.Commit() 367 } 368 369 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 370 var conditions []string 371 var args []any 372 for _, filter := range filters { ··· 466 idxs[did] = idx + 1 467 } 468 469 + return profileMap, nil 470 } 471 472 func GetProfile(e Execer, did string) (*Profile, error) { ··· 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 554 args = append(args, did, PullOpen) 555 case VanityStatOpenIssueCount: 556 + query = `select count(id) from issues where did = ? and open = 1` 557 args = append(args, did) 558 case VanityStatClosedIssueCount: 559 + query = `select count(id) from issues where did = ? and open = 0` 560 args = append(args, did) 561 case VanityStatRepositoryCount: 562 query = `select count(id) from repos where did = ?` ··· 590 } 591 592 // ensure all pinned repos are either own repos or collaborating repos 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 594 if err != nil { 595 log.Printf("getting repos for %s: %s", profile.Did, err) 596 }
+31 -11
appview/db/pulls.go
··· 91 } 92 93 record := tangled.RepoPull{ 94 - Title: p.Title, 95 - Body: &p.Body, 96 - CreatedAt: p.Created.Format(time.RFC3339), 97 - PullId: int64(p.PullId), 98 - TargetRepo: p.RepoAt.String(), 99 - TargetBranch: p.TargetBranch, 100 - Patch: p.LatestPatch(), 101 - Source: source, 102 } 103 return record 104 } ··· 310 return pullId - 1, err 311 } 312 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 314 pulls := make(map[int]*Pull) 315 316 var conditions []string ··· 324 if conditions != nil { 325 whereClause = " where " + strings.Join(conditions, " and ") 326 } 327 328 query := fmt.Sprintf(` 329 select ··· 344 from 345 pulls 346 %s 347 - `, whereClause) 348 349 rows, err := e.Query(query, args...) 350 if err != nil { ··· 412 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 submissionsQuery := fmt.Sprintf(` 414 select 415 - id, pull_id, round_number, patch, source_rev 416 from 417 pull_submissions 418 where ··· 438 for submissionsRows.Next() { 439 var s PullSubmission 440 var sourceRev sql.NullString 441 err := submissionsRows.Scan( 442 &s.ID, 443 &s.PullId, 444 &s.RoundNumber, 445 &s.Patch, 446 &sourceRev, 447 ) 448 if err != nil { 449 return nil, err 450 } 451 452 if sourceRev.Valid { 453 s.SourceRev = sourceRev.String 454 } ··· 511 }) 512 513 return orderedByPullId, nil 514 } 515 516 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
··· 91 } 92 93 record := tangled.RepoPull{ 94 + Title: p.Title, 95 + Body: &p.Body, 96 + CreatedAt: p.Created.Format(time.RFC3339), 97 + Target: &tangled.RepoPull_Target{ 98 + Repo: p.RepoAt.String(), 99 + Branch: p.TargetBranch, 100 + }, 101 + Patch: p.LatestPatch(), 102 + Source: source, 103 } 104 return record 105 } ··· 311 return pullId - 1, err 312 } 313 314 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 315 pulls := make(map[int]*Pull) 316 317 var conditions []string ··· 325 if conditions != nil { 326 whereClause = " where " + strings.Join(conditions, " and ") 327 } 328 + limitClause := "" 329 + if limit != 0 { 330 + limitClause = fmt.Sprintf(" limit %d ", limit) 331 + } 332 333 query := fmt.Sprintf(` 334 select ··· 349 from 350 pulls 351 %s 352 + order by 353 + created desc 354 + %s 355 + `, whereClause, limitClause) 356 357 rows, err := e.Query(query, args...) 358 if err != nil { ··· 420 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 421 submissionsQuery := fmt.Sprintf(` 422 select 423 + id, pull_id, round_number, patch, created, source_rev 424 from 425 pull_submissions 426 where ··· 446 for submissionsRows.Next() { 447 var s PullSubmission 448 var sourceRev sql.NullString 449 + var createdAt string 450 err := submissionsRows.Scan( 451 &s.ID, 452 &s.PullId, 453 &s.RoundNumber, 454 &s.Patch, 455 + &createdAt, 456 &sourceRev, 457 ) 458 if err != nil { 459 return nil, err 460 } 461 462 + createdTime, err := time.Parse(time.RFC3339, createdAt) 463 + if err != nil { 464 + return nil, err 465 + } 466 + s.Created = createdTime 467 + 468 if sourceRev.Valid { 469 s.SourceRev = sourceRev.String 470 } ··· 527 }) 528 529 return orderedByPullId, nil 530 + } 531 + 532 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 533 + return GetPullsWithLimit(e, 0, filters...) 534 } 535 536 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+4 -4
appview/db/punchcard.go
··· 29 Punches []Punch 30 } 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 64 rows, err := e.Query(query, args...) 65 if err != nil { 66 - return punchcard, err 67 } 68 defer rows.Close() 69 ··· 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 76 } 77 78 punch.Date, err = time.Parse(time.DateOnly, date)
··· 29 Punches []Punch 30 } 31 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 64 rows, err := e.Query(query, args...) 65 if err != nil { 66 + return nil, err 67 } 68 defer rows.Close() 69 ··· 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil { 75 + return nil, err 76 } 77 78 punch.Date, err = time.Parse(time.DateOnly, date)
+7 -7
appview/db/reaction.go
··· 11 12 const ( 13 Like ReactionKind = "๐Ÿ‘" 14 - Unlike = "๐Ÿ‘Ž" 15 - Laugh = "๐Ÿ˜†" 16 - Celebration = "๐ŸŽ‰" 17 - Confused = "๐Ÿซค" 18 - Heart = "โค๏ธ" 19 - Rocket = "๐Ÿš€" 20 - Eyes = "๐Ÿ‘€" 21 ) 22 23 func (rk ReactionKind) String() string {
··· 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 {
+94 -130
appview/db/registration.go
··· 1 package db 2 3 import ( 4 - "crypto/rand" 5 "database/sql" 6 - "encoding/hex" 7 "fmt" 8 - "log" 9 "time" 10 ) 11 12 type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 } 19 20 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 return Registered 23 } else { 24 return Pending 25 } 26 } 27 28 type Status uint32 29 30 const ( 31 Registered Status = iota 32 Pending 33 ) 34 35 - // returns registered status, did of owner, error 36 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 37 var registrations []Registration 38 39 - rows, err := e.Query(` 40 - select id, domain, did, created, registered from registrations 41 - where did = ? 42 - `, did) 43 if err != nil { 44 return nil, err 45 } 46 47 for rows.Next() { 48 - var createdAt *string 49 - var registeredAt *string 50 - var registration Registration 51 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 52 53 if err != nil { 54 - log.Println(err) 55 - } else { 56 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 57 - var registeredAtTime *time.Time 58 - if registeredAt != nil { 59 - x, _ := time.Parse(time.RFC3339, *registeredAt) 60 - registeredAtTime = &x 61 - } 62 - 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 66 } 67 - } 68 69 - return registrations, nil 70 - } 71 - 72 - // returns registered status, did of owner, error 73 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 74 - var createdAt *string 75 - var registeredAt *string 76 - var registration Registration 77 78 - err := e.QueryRow(` 79 - select id, domain, did, created, registered from registrations 80 - where domain = ? 81 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 82 83 - if err != nil { 84 - if err == sql.ErrNoRows { 85 - return nil, nil 86 - } else { 87 - return nil, err 88 } 89 - } 90 91 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 92 - var registeredAtTime *time.Time 93 - if registeredAt != nil { 94 - x, _ := time.Parse(time.RFC3339, *registeredAt) 95 - registeredAtTime = &x 96 } 97 98 - registration.Created = &createdAtTime 99 - registration.Registered = registeredAtTime 100 - 101 - return &registration, nil 102 - } 103 - 104 - func genSecret() string { 105 - key := make([]byte, 32) 106 - rand.Read(key) 107 - return hex.EncodeToString(key) 108 } 109 110 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 111 - // sanity check: does this domain already have a registration? 112 - reg, err := RegistrationByDomain(e, domain) 113 - if err != nil { 114 - return "", err 115 - } 116 - 117 - // registration is open 118 - if reg != nil { 119 - switch reg.Status() { 120 - case Registered: 121 - // already registered by `owner` 122 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 123 - case Pending: 124 - // TODO: be loud about this 125 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 126 - } 127 } 128 129 - secret := genSecret() 130 - 131 - _, err = e.Exec(` 132 - insert into registrations (domain, did, secret) 133 - values (?, ?, ?) 134 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 135 - `, domain, did, secret) 136 - 137 - if err != nil { 138 - return "", err 139 } 140 141 - return secret, nil 142 } 143 144 - func GetRegistrationKey(e Execer, domain string) (string, error) { 145 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 146 - 147 - var secret string 148 - err := res.Scan(&secret) 149 - if err != nil || secret == "" { 150 - return "", err 151 - } 152 - 153 - return secret, nil 154 } 155 156 - func GetCompletedRegistrations(e Execer) ([]string, error) { 157 - rows, err := e.Query(`select domain from registrations where registered not null`) 158 - if err != nil { 159 - return nil, err 160 - } 161 - 162 - var domains []string 163 - for rows.Next() { 164 - var domain string 165 - err = rows.Scan(&domain) 166 - 167 - if err != nil { 168 - log.Println(err) 169 - } else { 170 - domains = append(domains, domain) 171 - } 172 } 173 174 - if err = rows.Err(); err != nil { 175 - return nil, err 176 } 177 178 - return domains, nil 179 - } 180 - 181 - func Register(e Execer, domain string) error { 182 - _, err := e.Exec(` 183 - update registrations 184 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 185 - where domain = ?; 186 - `, domain) 187 188 return err 189 }
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 + "strings" 7 "time" 8 ) 9 10 + // Registration represents a knot registration. Knot would've been a better 11 + // name but we're stuck with this for historical reasons. 12 type Registration struct { 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 19 } 20 21 func (r *Registration) Status() Status { 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 24 + } else if r.Registered != nil { 25 return Registered 26 } else { 27 return Pending 28 } 29 } 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 43 type Status uint32 44 45 const ( 46 Registered Status = iota 47 Pending 48 + NeedsUpgrade 49 ) 50 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 var registrations []Registration 53 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 59 + } 60 + 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 64 + } 65 + 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, needs_upgrade 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 74 + 75 + rows, err := e.Query(query, args...) 76 if err != nil { 77 return nil, err 78 } 79 80 for rows.Next() { 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var needsUpgrade int 84 + var reg Registration 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 if err != nil { 88 + return nil, err 89 } 90 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 93 + } 94 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 100 101 + if needsUpgrade != 0 { 102 + reg.NeedsUpgrade = true 103 } 104 105 + registrations = append(registrations, reg) 106 } 107 108 + return registrations, nil 109 } 110 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 117 } 118 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 122 } 123 124 + _, err := e.Exec(query, args...) 125 + return err 126 } 127 128 + func AddKnot(e Execer, domain, did string) error { 129 + _, err := e.Exec(` 130 + insert into registrations (domain, did) 131 + values (?, ?) 132 + `, domain, did) 133 + return err 134 } 135 136 + func DeleteKnot(e Execer, filters ...filter) error { 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 142 } 143 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 147 } 148 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 150 151 + _, err := e.Exec(query, args...) 152 return err 153 }
+36 -174
appview/db/repos.go
··· 2 3 import ( 4 "database/sql" 5 "fmt" 6 "log" 7 "slices" ··· 19 Knot string 20 Rkey string 21 Created time.Time 22 - AtUri string 23 Description string 24 Spindle string 25 ··· 37 func (r Repo) DidSlashRepo() string { 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 39 return p 40 - } 41 - 42 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 43 - var repos []Repo 44 - 45 - rows, err := e.Query( 46 - `select did, name, knot, rkey, description, created, source 47 - from repos 48 - order by created desc 49 - limit ? 50 - `, 51 - limit, 52 - ) 53 - if err != nil { 54 - return nil, err 55 - } 56 - defer rows.Close() 57 - 58 - for rows.Next() { 59 - var repo Repo 60 - err := scanRepo( 61 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 62 - ) 63 - if err != nil { 64 - return nil, err 65 - } 66 - repos = append(repos, repo) 67 - } 68 - 69 - if err := rows.Err(); err != nil { 70 - return nil, err 71 - } 72 - 73 - return repos, nil 74 } 75 76 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 311 312 slices.SortFunc(repos, func(a, b Repo) int { 313 if a.Created.After(b.Created) { 314 - return 1 315 } 316 - return -1 317 }) 318 319 return repos, nil 320 } 321 322 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 323 - var repos []Repo 324 - 325 - rows, err := e.Query( 326 - `select 327 - r.did, 328 - r.name, 329 - r.knot, 330 - r.rkey, 331 - r.description, 332 - r.created, 333 - count(s.id) as star_count, 334 - r.source 335 - from 336 - repos r 337 - left join 338 - stars s on r.at_uri = s.repo_at 339 - where 340 - r.did = ? 341 - group by 342 - r.at_uri 343 - order by r.created desc`, 344 - did) 345 - if err != nil { 346 - return nil, err 347 } 348 - defer rows.Close() 349 - 350 - for rows.Next() { 351 - var repo Repo 352 - var repoStats RepoStats 353 - var createdAt string 354 - var nullableDescription sql.NullString 355 - var nullableSource sql.NullString 356 - 357 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 358 - if err != nil { 359 - return nil, err 360 - } 361 - 362 - if nullableDescription.Valid { 363 - repo.Description = nullableDescription.String 364 - } 365 366 - if nullableSource.Valid { 367 - repo.Source = nullableSource.String 368 - } 369 - 370 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 371 - if err != nil { 372 - repo.Created = time.Now() 373 - } else { 374 - repo.Created = createdAtTime 375 - } 376 - 377 - repo.RepoStats = &repoStats 378 - 379 - repos = append(repos, repo) 380 } 381 382 - if err := rows.Err(); err != nil { 383 - return nil, err 384 } 385 386 - return repos, nil 387 } 388 389 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 391 var description, spindle sql.NullString 392 393 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 395 from repos 396 where did = ? and name = ? 397 `, ··· 400 ) 401 402 var createdAt string 403 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 404 return nil, err 405 } 406 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 421 var repo Repo 422 var nullableDescription sql.NullString 423 424 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 425 426 var createdAt string 427 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 428 return nil, err 429 } 430 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 444 `insert into repos 445 (did, name, knot, rkey, at_uri, description, source) 446 values (?, ?, ?, ?, ?, ?, ?)`, 447 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 448 ) 449 return err 450 } ··· 467 var repos []Repo 468 469 rows, err := e.Query( 470 - `select did, name, knot, rkey, description, created, at_uri, source 471 - from repos 472 - where did = ? and source is not null and source != '' 473 - order by created desc`, 474 - did, 475 ) 476 if err != nil { 477 return nil, err ··· 484 var nullableDescription sql.NullString 485 var nullableSource sql.NullString 486 487 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 488 if err != nil { 489 return nil, err 490 } ··· 521 var nullableSource sql.NullString 522 523 row := e.QueryRow( 524 - `select did, name, knot, rkey, description, created, at_uri, source 525 from repos 526 where did = ? and name = ? and source is not null and source != ''`, 527 did, name, 528 ) 529 530 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 531 if err != nil { 532 return nil, err 533 } ··· 550 return &repo, nil 551 } 552 553 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 554 - _, err := e.Exec( 555 - `insert into collaborators (did, repo) 556 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 557 - collaborator, repoOwnerDid, repoName, repoKnot) 558 - return err 559 - } 560 - 561 func UpdateDescription(e Execer, repoAt, newDescription string) error { 562 _, err := e.Exec( 563 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 564 return err 565 } 566 567 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 568 _, err := e.Exec( 569 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 570 return err 571 } 572 573 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 574 - rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator) 575 - if err != nil { 576 - return nil, err 577 - } 578 - defer rows.Close() 579 - 580 - var repoIds []int 581 - for rows.Next() { 582 - var id int 583 - err := rows.Scan(&id) 584 - if err != nil { 585 - return nil, err 586 - } 587 - repoIds = append(repoIds, id) 588 - } 589 - if err := rows.Err(); err != nil { 590 - return nil, err 591 - } 592 - if repoIds == nil { 593 - return nil, nil 594 - } 595 - 596 - return GetRepos(e, 0, FilterIn("id", repoIds)) 597 - } 598 - 599 type RepoStats struct { 600 Language string 601 StarCount int 602 IssueCount IssueCount 603 PullCount PullCount 604 } 605 - 606 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 607 - var createdAt string 608 - var nullableDescription sql.NullString 609 - var nullableSource sql.NullString 610 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 611 - return err 612 - } 613 - 614 - if nullableDescription.Valid { 615 - *description = nullableDescription.String 616 - } else { 617 - *description = "" 618 - } 619 - 620 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 621 - if err != nil { 622 - *created = time.Now() 623 - } else { 624 - *created = createdAtTime 625 - } 626 - 627 - if nullableSource.Valid { 628 - *source = nullableSource.String 629 - } else { 630 - *source = "" 631 - } 632 - 633 - return nil 634 - }
··· 2 3 import ( 4 "database/sql" 5 + "errors" 6 "fmt" 7 "log" 8 "slices" ··· 20 Knot string 21 Rkey string 22 Created time.Time 23 Description string 24 Spindle string 25 ··· 37 func (r Repo) DidSlashRepo() string { 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 39 return p 40 } 41 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 277 278 slices.SortFunc(repos, func(a, b Repo) int { 279 if a.Created.After(b.Created) { 280 + return -1 281 } 282 + return 1 283 }) 284 285 return repos, nil 286 } 287 288 + func CountRepos(e Execer, filters ...filter) (int64, error) { 289 + var conditions []string 290 + var args []any 291 + for _, filter := range filters { 292 + conditions = append(conditions, filter.Condition()) 293 + args = append(args, filter.Arg()...) 294 } 295 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 299 } 300 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 304 + 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 307 } 308 309 + return count, nil 310 } 311 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 314 var description, spindle sql.NullString 315 316 row := e.QueryRow(` 317 + select did, name, knot, created, description, spindle, rkey 318 from repos 319 where did = ? and name = ? 320 `, ··· 323 ) 324 325 var createdAt string 326 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 327 return nil, err 328 } 329 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 344 var repo Repo 345 var nullableDescription sql.NullString 346 347 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 348 349 var createdAt string 350 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 351 return nil, err 352 } 353 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 367 `insert into repos 368 (did, name, knot, rkey, at_uri, description, source) 369 values (?, ?, ?, ?, ?, ?, ?)`, 370 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 371 ) 372 return err 373 } ··· 390 var repos []Repo 391 392 rows, err := e.Query( 393 + `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 394 + from repos r 395 + left join collaborators c on r.at_uri = c.repo_at 396 + where (r.did = ? or c.subject_did = ?) 397 + and r.source is not null 398 + and r.source != '' 399 + order by r.created desc`, 400 + did, did, 401 ) 402 if err != nil { 403 return nil, err ··· 410 var nullableDescription sql.NullString 411 var nullableSource sql.NullString 412 413 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 414 if err != nil { 415 return nil, err 416 } ··· 447 var nullableSource sql.NullString 448 449 row := e.QueryRow( 450 + `select did, name, knot, rkey, description, created, source 451 from repos 452 where did = ? and name = ? and source is not null and source != ''`, 453 did, name, 454 ) 455 456 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 457 if err != nil { 458 return nil, err 459 } ··· 476 return &repo, nil 477 } 478 479 func UpdateDescription(e Execer, repoAt, newDescription string) error { 480 _, err := e.Exec( 481 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 482 return err 483 } 484 485 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 486 _, err := e.Exec( 487 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 488 return err 489 } 490 491 type RepoStats struct { 492 Language string 493 StarCount int 494 IssueCount IssueCount 495 PullCount PullCount 496 }
+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 + }
+14 -7
appview/db/spindle.go
··· 10 ) 11 12 type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 18 } 19 20 type SpindleMember struct { ··· 42 } 43 44 query := fmt.Sprintf( 45 - `select id, owner, instance, verified, created 46 from spindles 47 %s 48 order by created ··· 61 var spindle Spindle 62 var createdAt string 63 var verified sql.NullString 64 65 if err := rows.Scan( 66 &spindle.Id, ··· 68 &spindle.Instance, 69 &verified, 70 &createdAt, 71 ); err != nil { 72 return nil, err 73 } ··· 86 spindle.Verified = &t 87 } 88 89 spindles = append(spindles, spindle) 90 } 91 ··· 115 whereClause = " where " + strings.Join(conditions, " and ") 116 } 117 118 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 119 120 res, err := e.Exec(query, args...) 121 if err != nil {
··· 10 ) 11 12 type Spindle struct { 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + NeedsUpgrade bool 19 } 20 21 type SpindleMember struct { ··· 43 } 44 45 query := fmt.Sprintf( 46 + `select id, owner, instance, verified, created, needs_upgrade 47 from spindles 48 %s 49 order by created ··· 62 var spindle Spindle 63 var createdAt string 64 var verified sql.NullString 65 + var needsUpgrade int 66 67 if err := rows.Scan( 68 &spindle.Id, ··· 70 &spindle.Instance, 71 &verified, 72 &createdAt, 73 + &needsUpgrade, 74 ); err != nil { 75 return nil, err 76 } ··· 89 spindle.Verified = &t 90 } 91 92 + if needsUpgrade != 0 { 93 + spindle.NeedsUpgrade = true 94 + } 95 + 96 spindles = append(spindles, spindle) 97 } 98 ··· 122 whereClause = " where " + strings.Join(conditions, " and ") 123 } 124 125 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 126 127 res, err := e.Exec(query, args...) 128 if err != nil {
+100 -6
appview/db/star.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "log" 6 "strings" ··· 47 // Get a star record 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 51 from stars 52 where starred_by_did = ? and repo_at = ?` 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 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 ··· 183 return stars, nil 184 } 185 186 func GetAllStars(e Execer, limit int) ([]Star, error) { 187 var stars []Star 188 189 rows, err := e.Query(` 190 - select 191 s.starred_by_did, 192 s.repo_at, 193 s.rkey, ··· 196 r.name, 197 r.knot, 198 r.rkey, 199 - r.created, 200 - r.at_uri 201 from stars s 202 join repos r on s.repo_at = r.at_uri 203 `) ··· 222 &repo.Knot, 223 &repo.Rkey, 224 &repoCreatedAt, 225 - &repo.AtUri, 226 ); err != nil { 227 return nil, err 228 } ··· 246 247 return stars, nil 248 }
··· 1 package db 2 3 import ( 4 + "database/sql" 5 + "errors" 6 "fmt" 7 "log" 8 "strings" ··· 49 // Get a star record 50 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 51 query := ` 52 + select starred_by_did, repo_at, created, rkey 53 from stars 54 where starred_by_did = ? and repo_at = ?` 55 row := e.QueryRow(query, starredByDid, repoAt) ··· 121 } 122 123 repoQuery := fmt.Sprintf( 124 + `select starred_by_did, repo_at, created, rkey 125 from stars 126 %s 127 order by created desc ··· 185 return stars, nil 186 } 187 188 + func CountStars(e Execer, filters ...filter) (int64, error) { 189 + var conditions []string 190 + var args []any 191 + for _, filter := range filters { 192 + conditions = append(conditions, filter.Condition()) 193 + args = append(args, filter.Arg()...) 194 + } 195 + 196 + whereClause := "" 197 + if conditions != nil { 198 + whereClause = " where " + strings.Join(conditions, " and ") 199 + } 200 + 201 + repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause) 202 + var count int64 203 + err := e.QueryRow(repoQuery, args...).Scan(&count) 204 + 205 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 206 + return 0, err 207 + } 208 + 209 + return count, nil 210 + } 211 + 212 func GetAllStars(e Execer, limit int) ([]Star, error) { 213 var stars []Star 214 215 rows, err := e.Query(` 216 + select 217 s.starred_by_did, 218 s.repo_at, 219 s.rkey, ··· 222 r.name, 223 r.knot, 224 r.rkey, 225 + r.created 226 from stars s 227 join repos r on s.repo_at = r.at_uri 228 `) ··· 247 &repo.Knot, 248 &repo.Rkey, 249 &repoCreatedAt, 250 ); err != nil { 251 return nil, err 252 } ··· 270 271 return stars, nil 272 } 273 + 274 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 275 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 276 + // first, get the top repo URIs by star count from the last week 277 + query := ` 278 + with recent_starred_repos as ( 279 + select distinct repo_at 280 + from stars 281 + where created >= datetime('now', '-7 days') 282 + ), 283 + repo_star_counts as ( 284 + select 285 + s.repo_at, 286 + count(*) as stars_gained_last_week 287 + from stars s 288 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 289 + where s.created >= datetime('now', '-7 days') 290 + group by s.repo_at 291 + ) 292 + select rsc.repo_at 293 + from repo_star_counts rsc 294 + order by rsc.stars_gained_last_week desc 295 + limit 8 296 + ` 297 + 298 + rows, err := e.Query(query) 299 + if err != nil { 300 + return nil, err 301 + } 302 + defer rows.Close() 303 + 304 + var repoUris []string 305 + for rows.Next() { 306 + var repoUri string 307 + err := rows.Scan(&repoUri) 308 + if err != nil { 309 + return nil, err 310 + } 311 + repoUris = append(repoUris, repoUri) 312 + } 313 + 314 + if err := rows.Err(); err != nil { 315 + return nil, err 316 + } 317 + 318 + if len(repoUris) == 0 { 319 + return []Repo{}, nil 320 + } 321 + 322 + // get full repo data 323 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 324 + if err != nil { 325 + return nil, err 326 + } 327 + 328 + // sort repos by the original trending order 329 + repoMap := make(map[string]Repo) 330 + for _, repo := range repos { 331 + repoMap[repo.RepoAt().String()] = repo 332 + } 333 + 334 + orderedRepos := make([]Repo, 0, len(repoUris)) 335 + for _, uri := range repoUris { 336 + if repo, exists := repoMap[uri]; exists { 337 + orderedRepos = append(orderedRepos, repo) 338 + } 339 + } 340 + 341 + return orderedRepos, nil 342 + }
+276
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 CountStrings(e Execer, filters ...filter) (int64, error) { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause) 223 + var count int64 224 + err := e.QueryRow(repoQuery, args...).Scan(&count) 225 + 226 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 227 + return 0, err 228 + } 229 + 230 + return count, nil 231 + } 232 + 233 + func DeleteString(e Execer, filters ...filter) error { 234 + var conditions []string 235 + var args []any 236 + for _, filter := range filters { 237 + conditions = append(conditions, filter.Condition()) 238 + args = append(args, filter.Arg()...) 239 + } 240 + 241 + whereClause := "" 242 + if conditions != nil { 243 + whereClause = " where " + strings.Join(conditions, " and ") 244 + } 245 + 246 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 247 + 248 + _, err := e.Exec(query, args...) 249 + return err 250 + } 251 + 252 + func countLines(r io.Reader) (int, error) { 253 + buf := make([]byte, 32*1024) 254 + bufLen := 0 255 + count := 0 256 + nl := []byte{'\n'} 257 + 258 + for { 259 + c, err := r.Read(buf) 260 + if c > 0 { 261 + bufLen += c 262 + } 263 + count += bytes.Count(buf[:c], nl) 264 + 265 + switch { 266 + case err == io.EOF: 267 + /* handle last line not having a newline at the end */ 268 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 269 + count++ 270 + } 271 + return count, nil 272 + case err != nil: 273 + return 0, err 274 + } 275 + } 276 + }
+17 -35
appview/db/timeline.go
··· 20 *FollowStats 21 } 22 23 - type FollowStats struct { 24 - Followers int 25 - Following int 26 - } 27 - 28 - const Limit = 50 29 - 30 // TODO: this gathers heterogenous events from different sources and aggregates 31 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 32 - func MakeTimeline(e Execer) ([]TimelineEvent, error) { 33 var events []TimelineEvent 34 35 - repos, err := getTimelineRepos(e) 36 if err != nil { 37 return nil, err 38 } 39 40 - stars, err := getTimelineStars(e) 41 if err != nil { 42 return nil, err 43 } 44 45 - follows, err := getTimelineFollows(e) 46 if err != nil { 47 return nil, err 48 } ··· 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 } ··· 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 } ··· 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 } ··· 151 return nil, nil 152 } 153 154 - profileMap := make(map[string]Profile) 155 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 if err != nil { 157 return nil, err 158 } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 - } 162 163 - followStatMap := make(map[string]FollowStats) 164 - for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowing(e, s) 166 - if err != nil { 167 - return nil, err 168 - } 169 - followStatMap[s] = FollowStats{ 170 - Followers: followers, 171 - Following: following, 172 - } 173 } 174 175 var events []TimelineEvent 176 for _, f := range follows { 177 - profile, _ := profileMap[f.SubjectDid] 178 followStatMap, _ := followStatMap[f.SubjectDid] 179 180 events = append(events, TimelineEvent{ 181 Follow: &f, 182 - Profile: &profile, 183 FollowStats: &followStatMap, 184 EventAt: f.FollowedAt, 185 })
··· 20 *FollowStats 21 } 22 23 // TODO: this gathers heterogenous events from different sources and aggregates 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 26 var events []TimelineEvent 27 28 + repos, err := getTimelineRepos(e, limit) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit) 39 if err != nil { 40 return nil, err 41 } ··· 49 }) 50 51 // Limit the slice to 100 events 52 + if len(events) > limit { 53 + events = events[:limit] 54 } 55 56 return events, nil 57 } 58 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 61 if err != nil { 62 return nil, err 63 } ··· 102 return events, nil 103 } 104 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 107 if err != nil { 108 return nil, err 109 } ··· 129 return events, nil 130 } 131 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 134 if err != nil { 135 return nil, err 136 } ··· 144 return nil, nil 145 } 146 147 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 148 if err != nil { 149 return nil, err 150 } 151 152 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 153 + if err != nil { 154 + return nil, err 155 } 156 157 var events []TimelineEvent 158 for _, f := range follows { 159 + profile, _ := profiles[f.SubjectDid] 160 followStatMap, _ := followStatMap[f.SubjectDid] 161 162 events = append(events, TimelineEvent{ 163 Follow: &f, 164 + Profile: profile, 165 FollowStats: &followStatMap, 166 EventAt: f.FollowedAt, 167 })
+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 + }
+357 -10
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/spindleverify" 18 "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) ··· 25 IdResolver *idresolver.Resolver 26 Config *config.Config 27 Logger *slog.Logger 28 } 29 30 type processFunc func(ctx context.Context, e *models.Event) error ··· 61 case tangled.ActorProfileNSID: 62 err = i.ingestProfile(e) 63 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 67 } 68 l = i.Logger.With("nsid", e.Commit.Collection) 69 } 70 71 if err != nil { 72 - l.Error("error ingesting record", "err", err) 73 } 74 75 - return err 76 } 77 } 78 ··· 334 return nil 335 } 336 337 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 338 did := e.Did 339 var err error 340 ··· 357 return fmt.Errorf("failed to enforce permissions: %w", err) 358 } 359 360 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 361 if err != nil { 362 return err 363 } ··· 385 if err != nil { 386 return fmt.Errorf("failed to update ACLs: %w", err) 387 } 388 case models.CommitOperationDelete: 389 rkey := e.Commit.RKey 390 ··· 431 if err = i.Enforcer.E.SavePolicy(); err != nil { 432 return fmt.Errorf("failed to save ACLs: %w", err) 433 } 434 } 435 436 return nil 437 } 438 439 - func (i *Ingester) ingestSpindle(e *models.Event) error { 440 did := e.Did 441 var err error 442 ··· 469 return err 470 } 471 472 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 473 if err != nil { 474 l.Error("failed to add spindle to db", "err", err, "instance", instance) 475 return err 476 } 477 478 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 479 if err != nil { 480 return fmt.Errorf("failed to mark verified: %w", err) 481 } ··· 549 550 return nil 551 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 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/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) ··· 27 IdResolver *idresolver.Resolver 28 Config *config.Config 29 Logger *slog.Logger 30 + Validator *validator.Validator 31 } 32 33 type processFunc func(ctx context.Context, e *models.Event) error ··· 64 case tangled.ActorProfileNSID: 65 err = i.ingestProfile(e) 66 case tangled.SpindleMemberNSID: 67 + err = i.ingestSpindleMember(ctx, e) 68 case tangled.SpindleNSID: 69 + err = i.ingestSpindle(ctx, e) 70 + case tangled.KnotMemberNSID: 71 + err = i.ingestKnotMember(e) 72 + case tangled.KnotNSID: 73 + err = i.ingestKnot(e) 74 + case tangled.StringNSID: 75 + err = i.ingestString(e) 76 + case tangled.RepoIssueNSID: 77 + err = i.ingestIssue(ctx, e) 78 + case tangled.RepoIssueCommentNSID: 79 + err = i.ingestIssueComment(e) 80 } 81 l = i.Logger.With("nsid", e.Commit.Collection) 82 } 83 84 if err != nil { 85 + l.Debug("error ingesting record", "err", err) 86 } 87 88 + return nil 89 } 90 } 91 ··· 347 return nil 348 } 349 350 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 351 did := e.Did 352 var err error 353 ··· 370 return fmt.Errorf("failed to enforce permissions: %w", err) 371 } 372 373 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 374 if err != nil { 375 return err 376 } ··· 398 if err != nil { 399 return fmt.Errorf("failed to update ACLs: %w", err) 400 } 401 + 402 + l.Info("added spindle member") 403 case models.CommitOperationDelete: 404 rkey := e.Commit.RKey 405 ··· 446 if err = i.Enforcer.E.SavePolicy(); err != nil { 447 return fmt.Errorf("failed to save ACLs: %w", err) 448 } 449 + 450 + l.Info("removed spindle member") 451 } 452 453 return nil 454 } 455 456 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 457 did := e.Did 458 var err error 459 ··· 486 return err 487 } 488 489 + err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 490 if err != nil { 491 l.Error("failed to add spindle to db", "err", err, "instance", instance) 492 return err 493 } 494 495 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 496 if err != nil { 497 return fmt.Errorf("failed to mark verified: %w", err) 498 } ··· 566 567 return nil 568 } 569 + 570 + func (i *Ingester) ingestString(e *models.Event) error { 571 + did := e.Did 572 + rkey := e.Commit.RKey 573 + 574 + var err error 575 + 576 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 577 + l.Info("ingesting record") 578 + 579 + ddb, ok := i.Db.Execer.(*db.DB) 580 + if !ok { 581 + return fmt.Errorf("failed to index string record, invalid db cast") 582 + } 583 + 584 + switch e.Commit.Operation { 585 + case models.CommitOperationCreate, models.CommitOperationUpdate: 586 + raw := json.RawMessage(e.Commit.Record) 587 + record := tangled.String{} 588 + err = json.Unmarshal(raw, &record) 589 + if err != nil { 590 + l.Error("invalid record", "err", err) 591 + return err 592 + } 593 + 594 + string := db.StringFromRecord(did, rkey, record) 595 + 596 + if err = string.Validate(); err != nil { 597 + l.Error("invalid record", "err", err) 598 + return err 599 + } 600 + 601 + if err = db.AddString(ddb, string); err != nil { 602 + l.Error("failed to add string", "err", err) 603 + return err 604 + } 605 + 606 + return nil 607 + 608 + case models.CommitOperationDelete: 609 + if err := db.DeleteString( 610 + ddb, 611 + db.FilterEq("did", did), 612 + db.FilterEq("rkey", rkey), 613 + ); err != nil { 614 + l.Error("failed to delete", "err", err) 615 + return fmt.Errorf("failed to delete string record: %w", err) 616 + } 617 + 618 + return nil 619 + } 620 + 621 + return nil 622 + } 623 + 624 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 625 + did := e.Did 626 + var err error 627 + 628 + l := i.Logger.With("handler", "ingestKnotMember") 629 + l = l.With("nsid", e.Commit.Collection) 630 + 631 + switch e.Commit.Operation { 632 + case models.CommitOperationCreate: 633 + raw := json.RawMessage(e.Commit.Record) 634 + record := tangled.KnotMember{} 635 + err = json.Unmarshal(raw, &record) 636 + if err != nil { 637 + l.Error("invalid record", "err", err) 638 + return err 639 + } 640 + 641 + // only knot owner can invite to knots 642 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 643 + if err != nil || !ok { 644 + return fmt.Errorf("failed to enforce permissions: %w", err) 645 + } 646 + 647 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 648 + if err != nil { 649 + return err 650 + } 651 + 652 + if memberId.Handle.IsInvalidHandle() { 653 + return err 654 + } 655 + 656 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 657 + if err != nil { 658 + return fmt.Errorf("failed to update ACLs: %w", err) 659 + } 660 + 661 + l.Info("added knot member") 662 + case models.CommitOperationDelete: 663 + // we don't store knot members in a table (like we do for spindle) 664 + // and we can't remove this just yet. possibly fixed if we switch 665 + // to either: 666 + // 1. a knot_members table like with spindle and store the rkey 667 + // 2. use the knot host as the rkey 668 + // 669 + // TODO: implement member deletion 670 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 671 + } 672 + 673 + return nil 674 + } 675 + 676 + func (i *Ingester) ingestKnot(e *models.Event) error { 677 + did := e.Did 678 + var err error 679 + 680 + l := i.Logger.With("handler", "ingestKnot") 681 + l = l.With("nsid", e.Commit.Collection) 682 + 683 + switch e.Commit.Operation { 684 + case models.CommitOperationCreate: 685 + raw := json.RawMessage(e.Commit.Record) 686 + record := tangled.Knot{} 687 + err = json.Unmarshal(raw, &record) 688 + if err != nil { 689 + l.Error("invalid record", "err", err) 690 + return err 691 + } 692 + 693 + domain := e.Commit.RKey 694 + 695 + ddb, ok := i.Db.Execer.(*db.DB) 696 + if !ok { 697 + return fmt.Errorf("failed to index profile record, invalid db cast") 698 + } 699 + 700 + err := db.AddKnot(ddb, domain, did) 701 + if err != nil { 702 + l.Error("failed to add knot to db", "err", err, "domain", domain) 703 + return err 704 + } 705 + 706 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 707 + if err != nil { 708 + l.Error("failed to verify knot", "err", err, "domain", domain) 709 + return err 710 + } 711 + 712 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 713 + if err != nil { 714 + return fmt.Errorf("failed to mark verified: %w", err) 715 + } 716 + 717 + return nil 718 + 719 + case models.CommitOperationDelete: 720 + domain := e.Commit.RKey 721 + 722 + ddb, ok := i.Db.Execer.(*db.DB) 723 + if !ok { 724 + return fmt.Errorf("failed to index knot record, invalid db cast") 725 + } 726 + 727 + // get record from db first 728 + registrations, err := db.GetRegistrations( 729 + ddb, 730 + db.FilterEq("domain", domain), 731 + db.FilterEq("did", did), 732 + ) 733 + if err != nil { 734 + return fmt.Errorf("failed to get registration: %w", err) 735 + } 736 + if len(registrations) != 1 { 737 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 738 + } 739 + registration := registrations[0] 740 + 741 + tx, err := ddb.Begin() 742 + if err != nil { 743 + return err 744 + } 745 + defer func() { 746 + tx.Rollback() 747 + i.Enforcer.E.LoadPolicy() 748 + }() 749 + 750 + err = db.DeleteKnot( 751 + tx, 752 + db.FilterEq("did", did), 753 + db.FilterEq("domain", domain), 754 + ) 755 + if err != nil { 756 + return err 757 + } 758 + 759 + if registration.Registered != nil { 760 + err = i.Enforcer.RemoveKnot(domain) 761 + if err != nil { 762 + return err 763 + } 764 + } 765 + 766 + err = tx.Commit() 767 + if err != nil { 768 + return err 769 + } 770 + 771 + err = i.Enforcer.E.SavePolicy() 772 + if err != nil { 773 + return err 774 + } 775 + } 776 + 777 + return nil 778 + } 779 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 780 + did := e.Did 781 + rkey := e.Commit.RKey 782 + 783 + var err error 784 + 785 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 786 + l.Info("ingesting record") 787 + 788 + ddb, ok := i.Db.Execer.(*db.DB) 789 + if !ok { 790 + return fmt.Errorf("failed to index issue record, invalid db cast") 791 + } 792 + 793 + switch e.Commit.Operation { 794 + case models.CommitOperationCreate, models.CommitOperationUpdate: 795 + raw := json.RawMessage(e.Commit.Record) 796 + record := tangled.RepoIssue{} 797 + err = json.Unmarshal(raw, &record) 798 + if err != nil { 799 + l.Error("invalid record", "err", err) 800 + return err 801 + } 802 + 803 + issue := db.IssueFromRecord(did, rkey, record) 804 + 805 + if err := i.Validator.ValidateIssue(&issue); err != nil { 806 + return fmt.Errorf("failed to validate issue: %w", err) 807 + } 808 + 809 + tx, err := ddb.BeginTx(ctx, nil) 810 + if err != nil { 811 + l.Error("failed to begin transaction", "err", err) 812 + return err 813 + } 814 + defer tx.Rollback() 815 + 816 + err = db.PutIssue(tx, &issue) 817 + if err != nil { 818 + l.Error("failed to create issue", "err", err) 819 + return err 820 + } 821 + 822 + err = tx.Commit() 823 + if err != nil { 824 + l.Error("failed to commit txn", "err", err) 825 + return err 826 + } 827 + 828 + return nil 829 + 830 + case models.CommitOperationDelete: 831 + if err := db.DeleteIssues( 832 + ddb, 833 + db.FilterEq("did", did), 834 + db.FilterEq("rkey", rkey), 835 + ); err != nil { 836 + l.Error("failed to delete", "err", err) 837 + return fmt.Errorf("failed to delete issue record: %w", err) 838 + } 839 + 840 + return nil 841 + } 842 + 843 + return nil 844 + } 845 + 846 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 847 + did := e.Did 848 + rkey := e.Commit.RKey 849 + 850 + var err error 851 + 852 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 853 + l.Info("ingesting record") 854 + 855 + ddb, ok := i.Db.Execer.(*db.DB) 856 + if !ok { 857 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 858 + } 859 + 860 + switch e.Commit.Operation { 861 + case models.CommitOperationCreate, models.CommitOperationUpdate: 862 + raw := json.RawMessage(e.Commit.Record) 863 + record := tangled.RepoIssueComment{} 864 + err = json.Unmarshal(raw, &record) 865 + if err != nil { 866 + return fmt.Errorf("invalid record: %w", err) 867 + } 868 + 869 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 870 + if err != nil { 871 + return fmt.Errorf("failed to parse comment from record: %w", err) 872 + } 873 + 874 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 875 + return fmt.Errorf("failed to validate comment: %w", err) 876 + } 877 + 878 + _, err = db.AddIssueComment(ddb, *comment) 879 + if err != nil { 880 + return fmt.Errorf("failed to create issue comment: %w", err) 881 + } 882 + 883 + return nil 884 + 885 + case models.CommitOperationDelete: 886 + if err := db.DeleteIssueComments( 887 + ddb, 888 + db.FilterEq("did", did), 889 + db.FilterEq("rkey", rkey), 890 + ); err != nil { 891 + return fmt.Errorf("failed to delete issue comment record: %w", err) 892 + } 893 + 894 + return nil 895 + } 896 + 897 + return nil 898 + }
+474 -332
appview/issues/issues.go
··· 1 package issues 2 3 import ( 4 "fmt" 5 "log" 6 - mathrand "math/rand/v2" 7 "net/http" 8 "slices" 9 - "strconv" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/data" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" ··· 23 "tangled.sh/tangled.sh/core/appview/pages" 24 "tangled.sh/tangled.sh/core/appview/pagination" 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 "tangled.sh/tangled.sh/core/idresolver" 27 "tangled.sh/tangled.sh/core/tid" 28 ) 29 ··· 35 db *db.DB 36 config *config.Config 37 notifier notify.Notifier 38 } 39 40 func New( ··· 45 db *db.DB, 46 config *config.Config, 47 notifier notify.Notifier, 48 ) *Issues { 49 return &Issues{ 50 oauth: oauth, ··· 54 db: db, 55 config: config, 56 notifier: notifier, 57 } 58 } 59 60 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 61 user := rp.oauth.GetUser(r) 62 f, err := rp.repoResolver.Resolve(r) 63 if err != nil { ··· 65 return 66 } 67 68 - issueId := chi.URLParam(r, "issue") 69 - issueIdInt, err := strconv.Atoi(issueId) 70 - if err != nil { 71 - http.Error(w, "bad issue id", http.StatusBadRequest) 72 - log.Println("failed to parse issue id", err) 73 return 74 } 75 76 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 77 if err != nil { 78 - log.Println("failed to get issue and comments", err) 79 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 80 - return 81 - } 82 - 83 - reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 - if err != nil { 85 - log.Println("failed to get issue reactions") 86 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 } 88 89 userReactions := map[db.ReactionKind]bool{} 90 if user != nil { 91 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 } 93 94 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 95 if err != nil { 96 - log.Println("failed to resolve issue owner", err) 97 } 98 99 - identsToResolve := make([]string, len(comments)) 100 - for i, comment := range comments { 101 - identsToResolve[i] = comment.OwnerDid 102 } 103 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 104 - didHandleMap := make(map[string]string) 105 - for _, identity := range resolvedIds { 106 - if !identity.Handle.IsInvalidHandle() { 107 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 108 - } else { 109 - didHandleMap[identity.DID.String()] = identity.DID.String() 110 } 111 - } 112 113 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 114 - LoggedInUser: user, 115 - RepoInfo: f.RepoInfo(user), 116 - Issue: *issue, 117 - Comments: comments, 118 119 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 120 - DidHandleMap: didHandleMap, 121 122 - OrderedReactionKinds: db.OrderedReactionKinds, 123 - Reactions: reactionCountMap, 124 - UserReacted: userReactions, 125 - }) 126 127 } 128 129 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 130 user := rp.oauth.GetUser(r) 131 f, err := rp.repoResolver.Resolve(r) 132 if err != nil { 133 - log.Println("failed to get repo and knot", err) 134 return 135 } 136 137 - issueId := chi.URLParam(r, "issue") 138 - issueIdInt, err := strconv.Atoi(issueId) 139 if err != nil { 140 - http.Error(w, "bad issue id", http.StatusBadRequest) 141 - log.Println("failed to parse issue id", err) 142 return 143 } 144 145 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 146 if err != nil { 147 - log.Println("failed to get issue", err) 148 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 149 return 150 } 151 ··· 156 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 157 return user.Did == collab.Did 158 }) 159 - isIssueOwner := user.Did == issue.OwnerDid 160 161 // TODO: make this more granular 162 if isIssueOwner || isCollaborator { 163 - 164 - closed := tangled.RepoIssueStateClosed 165 - 166 - client, err := rp.oauth.AuthorizedClient(r) 167 - if err != nil { 168 - log.Println("failed to get authorized client", err) 169 - return 170 - } 171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 - Collection: tangled.RepoIssueStateNSID, 173 - Repo: user.Did, 174 - Rkey: tid.TID(), 175 - Record: &lexutil.LexiconTypeDecoder{ 176 - Val: &tangled.RepoIssueState{ 177 - Issue: issue.IssueAt, 178 - State: closed, 179 - }, 180 - }, 181 - }) 182 - 183 - if err != nil { 184 - log.Println("failed to update issue state", err) 185 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 186 - return 187 - } 188 - 189 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 190 if err != nil { 191 log.Println("failed to close issue", err) 192 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 193 return 194 } 195 196 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 197 return 198 } else { 199 log.Println("user is not permitted to close issue") ··· 203 } 204 205 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 206 user := rp.oauth.GetUser(r) 207 f, err := rp.repoResolver.Resolve(r) 208 if err != nil { ··· 210 return 211 } 212 213 - issueId := chi.URLParam(r, "issue") 214 - issueIdInt, err := strconv.Atoi(issueId) 215 - if err != nil { 216 - http.Error(w, "bad issue id", http.StatusBadRequest) 217 - log.Println("failed to parse issue id", err) 218 - return 219 - } 220 - 221 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 222 - if err != nil { 223 - log.Println("failed to get issue", err) 224 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 225 return 226 } 227 ··· 232 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 233 return user.Did == collab.Did 234 }) 235 - isIssueOwner := user.Did == issue.OwnerDid 236 237 if isCollaborator || isIssueOwner { 238 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 239 if err != nil { 240 log.Println("failed to reopen issue", err) 241 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 242 return 243 } 244 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 245 return 246 } else { 247 log.Println("user is not the owner of the repo") ··· 251 } 252 253 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 254 user := rp.oauth.GetUser(r) 255 f, err := rp.repoResolver.Resolve(r) 256 if err != nil { 257 - log.Println("failed to get repo and knot", err) 258 return 259 } 260 261 - issueId := chi.URLParam(r, "issue") 262 - issueIdInt, err := strconv.Atoi(issueId) 263 - if err != nil { 264 - http.Error(w, "bad issue id", http.StatusBadRequest) 265 - log.Println("failed to parse issue id", err) 266 return 267 } 268 269 - switch r.Method { 270 - case http.MethodPost: 271 - body := r.FormValue("body") 272 - if body == "" { 273 - rp.pages.Notice(w, "issue", "Body is required") 274 - return 275 - } 276 277 - commentId := mathrand.IntN(1000000) 278 - rkey := tid.TID() 279 - 280 - err := db.NewIssueComment(rp.db, &db.Comment{ 281 - OwnerDid: user.Did, 282 - RepoAt: f.RepoAt, 283 - Issue: issueIdInt, 284 - CommentId: commentId, 285 - Body: body, 286 - Rkey: rkey, 287 - }) 288 - if err != nil { 289 - log.Println("failed to create comment", err) 290 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 291 - return 292 - } 293 - 294 - createdAt := time.Now().Format(time.RFC3339) 295 - commentIdInt64 := int64(commentId) 296 - ownerDid := user.Did 297 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 298 - if err != nil { 299 - log.Println("failed to get issue at", err) 300 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 301 - return 302 - } 303 - 304 - atUri := f.RepoAt.String() 305 - client, err := rp.oauth.AuthorizedClient(r) 306 - if err != nil { 307 - log.Println("failed to get authorized client", err) 308 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 309 - return 310 - } 311 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 312 - Collection: tangled.RepoIssueCommentNSID, 313 - Repo: user.Did, 314 - Rkey: rkey, 315 - Record: &lexutil.LexiconTypeDecoder{ 316 - Val: &tangled.RepoIssueComment{ 317 - Repo: &atUri, 318 - Issue: issueAt, 319 - CommentId: &commentIdInt64, 320 - Owner: &ownerDid, 321 - Body: body, 322 - CreatedAt: createdAt, 323 - }, 324 - }, 325 - }) 326 - if err != nil { 327 - log.Println("failed to create comment", err) 328 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 329 - return 330 - } 331 332 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 333 return 334 } 335 - } 336 337 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 338 - user := rp.oauth.GetUser(r) 339 - f, err := rp.repoResolver.Resolve(r) 340 if err != nil { 341 - log.Println("failed to get repo and knot", err) 342 return 343 } 344 345 - issueId := chi.URLParam(r, "issue") 346 - issueIdInt, err := strconv.Atoi(issueId) 347 if err != nil { 348 - http.Error(w, "bad issue id", http.StatusBadRequest) 349 - log.Println("failed to parse issue id", err) 350 return 351 } 352 353 - commentId := chi.URLParam(r, "comment_id") 354 - commentIdInt, err := strconv.Atoi(commentId) 355 if err != nil { 356 - http.Error(w, "bad comment id", http.StatusBadRequest) 357 - log.Println("failed to parse issue id", err) 358 return 359 } 360 361 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 362 if err != nil { 363 - log.Println("failed to get issue", err) 364 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 365 return 366 } 367 368 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 369 - if err != nil { 370 - http.Error(w, "bad comment id", http.StatusBadRequest) 371 return 372 } 373 374 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 375 if err != nil { 376 - log.Println("failed to resolve did") 377 return 378 } 379 - 380 - didHandleMap := make(map[string]string) 381 - if !identity.Handle.IsInvalidHandle() { 382 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 383 - } else { 384 - didHandleMap[identity.DID.String()] = identity.DID.String() 385 } 386 387 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 388 LoggedInUser: user, 389 RepoInfo: f.RepoInfo(user), 390 - DidHandleMap: didHandleMap, 391 Issue: issue, 392 - Comment: comment, 393 }) 394 } 395 396 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 397 user := rp.oauth.GetUser(r) 398 f, err := rp.repoResolver.Resolve(r) 399 if err != nil { 400 - log.Println("failed to get repo and knot", err) 401 return 402 } 403 404 - issueId := chi.URLParam(r, "issue") 405 - issueIdInt, err := strconv.Atoi(issueId) 406 - if err != nil { 407 - http.Error(w, "bad issue id", http.StatusBadRequest) 408 - log.Println("failed to parse issue id", err) 409 return 410 } 411 412 - commentId := chi.URLParam(r, "comment_id") 413 - commentIdInt, err := strconv.Atoi(commentId) 414 - if err != nil { 415 - http.Error(w, "bad comment id", http.StatusBadRequest) 416 - log.Println("failed to parse issue id", err) 417 - return 418 - } 419 - 420 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 421 if err != nil { 422 - log.Println("failed to get issue", err) 423 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 424 return 425 } 426 - 427 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 428 - if err != nil { 429 - http.Error(w, "bad comment id", http.StatusBadRequest) 430 return 431 } 432 433 - if comment.OwnerDid != user.Did { 434 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 435 return 436 } ··· 441 LoggedInUser: user, 442 RepoInfo: f.RepoInfo(user), 443 Issue: issue, 444 - Comment: comment, 445 }) 446 case http.MethodPost: 447 // extract form value ··· 452 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 453 return 454 } 455 - rkey := comment.Rkey 456 457 - // optimistic update 458 - edited := time.Now() 459 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 460 if err != nil { 461 log.Println("failed to perferom update-description query", err) 462 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 464 } 465 466 // rkey is optional, it was introduced later 467 - if comment.Rkey != "" { 468 // update the record on pds 469 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 470 if err != nil { 471 - // failed to get record 472 - log.Println(err, rkey) 473 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 474 return 475 } 476 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 477 - record, _ := data.UnmarshalJSON(value) 478 - 479 - repoAt := record["repo"].(string) 480 - issueAt := record["issue"].(string) 481 - createdAt := record["createdAt"].(string) 482 - commentIdInt64 := int64(commentIdInt) 483 484 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 485 Collection: tangled.RepoIssueCommentNSID, 486 Repo: user.Did, 487 - Rkey: rkey, 488 SwapRecord: ex.Cid, 489 Record: &lexutil.LexiconTypeDecoder{ 490 - Val: &tangled.RepoIssueComment{ 491 - Repo: &repoAt, 492 - Issue: issueAt, 493 - CommentId: &commentIdInt64, 494 - Owner: &comment.OwnerDid, 495 - Body: newBody, 496 - CreatedAt: createdAt, 497 - }, 498 }, 499 }) 500 if err != nil { 501 - log.Println(err) 502 } 503 } 504 - 505 - // optimistic update for htmx 506 - didHandleMap := map[string]string{ 507 - user.Did: user.Handle, 508 - } 509 - comment.Body = newBody 510 - comment.Edited = &edited 511 512 // return new comment body with htmx 513 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 514 LoggedInUser: user, 515 RepoInfo: f.RepoInfo(user), 516 - DidHandleMap: didHandleMap, 517 Issue: issue, 518 - Comment: comment, 519 }) 520 return 521 522 } 523 524 } 525 526 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 527 user := rp.oauth.GetUser(r) 528 f, err := rp.repoResolver.Resolve(r) 529 if err != nil { 530 - log.Println("failed to get repo and knot", err) 531 return 532 } 533 534 - issueId := chi.URLParam(r, "issue") 535 - issueIdInt, err := strconv.Atoi(issueId) 536 if err != nil { 537 - http.Error(w, "bad issue id", http.StatusBadRequest) 538 - log.Println("failed to parse issue id", err) 539 return 540 } 541 542 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 543 if err != nil { 544 - log.Println("failed to get issue", err) 545 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 546 return 547 } 548 549 - commentId := chi.URLParam(r, "comment_id") 550 - commentIdInt, err := strconv.Atoi(commentId) 551 - if err != nil { 552 - http.Error(w, "bad comment id", http.StatusBadRequest) 553 - log.Println("failed to parse issue id", err) 554 return 555 } 556 557 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 558 if err != nil { 559 - http.Error(w, "bad comment id", http.StatusBadRequest) 560 return 561 } 562 563 - if comment.OwnerDid != user.Did { 564 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 return 566 } ··· 572 573 // optimistic deletion 574 deleted := time.Now() 575 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 576 if err != nil { 577 - log.Println("failed to delete comment") 578 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 579 return 580 } ··· 588 return 589 } 590 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 591 - Collection: tangled.GraphFollowNSID, 592 Repo: user.Did, 593 Rkey: comment.Rkey, 594 }) ··· 598 } 599 600 // optimistic update for htmx 601 - didHandleMap := map[string]string{ 602 - user.Did: user.Handle, 603 - } 604 comment.Body = "" 605 comment.Deleted = &deleted 606 607 // htmx fragment of comment after deletion 608 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 609 LoggedInUser: user, 610 RepoInfo: f.RepoInfo(user), 611 - DidHandleMap: didHandleMap, 612 Issue: issue, 613 - Comment: comment, 614 }) 615 - return 616 } 617 618 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 641 return 642 } 643 644 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 645 if err != nil { 646 log.Println("failed to get issues", err) 647 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 648 return 649 } 650 651 - identsToResolve := make([]string, len(issues)) 652 - for i, issue := range issues { 653 - identsToResolve[i] = issue.OwnerDid 654 - } 655 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 656 - didHandleMap := make(map[string]string) 657 - for _, identity := range resolvedIds { 658 - if !identity.Handle.IsInvalidHandle() { 659 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 660 - } else { 661 - didHandleMap[identity.DID.String()] = identity.DID.String() 662 - } 663 - } 664 - 665 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 666 LoggedInUser: rp.oauth.GetUser(r), 667 RepoInfo: f.RepoInfo(user), 668 Issues: issues, 669 - DidHandleMap: didHandleMap, 670 FilteringByOpen: isOpen, 671 Page: page, 672 }) 673 - return 674 } 675 676 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 677 user := rp.oauth.GetUser(r) 678 679 f, err := rp.repoResolver.Resolve(r) 680 if err != nil { 681 - log.Println("failed to get repo and knot", err) 682 return 683 } 684 ··· 689 RepoInfo: f.RepoInfo(user), 690 }) 691 case http.MethodPost: 692 - title := r.FormValue("title") 693 - body := r.FormValue("body") 694 - 695 - if title == "" || body == "" { 696 - rp.pages.Notice(w, "issues", "Title and body are required") 697 - return 698 } 699 700 - tx, err := rp.db.BeginTx(r.Context(), nil) 701 - if err != nil { 702 - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 703 return 704 } 705 706 - issue := &db.Issue{ 707 - RepoAt: f.RepoAt, 708 - Title: title, 709 - Body: body, 710 - OwnerDid: user.Did, 711 - } 712 - err = db.NewIssue(tx, issue) 713 - if err != nil { 714 - log.Println("failed to create issue", err) 715 - rp.pages.Notice(w, "issues", "Failed to create issue.") 716 - return 717 - } 718 719 client, err := rp.oauth.AuthorizedClient(r) 720 if err != nil { 721 - log.Println("failed to get authorized client", err) 722 rp.pages.Notice(w, "issues", "Failed to create issue.") 723 return 724 } 725 - atUri := f.RepoAt.String() 726 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 Collection: tangled.RepoIssueNSID, 728 Repo: user.Did, 729 - Rkey: tid.TID(), 730 Record: &lexutil.LexiconTypeDecoder{ 731 - Val: &tangled.RepoIssue{ 732 - Repo: atUri, 733 - Title: title, 734 - Body: &body, 735 - Owner: user.Did, 736 - IssueId: int64(issue.IssueId), 737 - }, 738 }, 739 }) 740 if err != nil { 741 - log.Println("failed to create issue", err) 742 rp.pages.Notice(w, "issues", "Failed to create issue.") 743 return 744 } 745 746 - err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 747 if err != nil { 748 - log.Println("failed to set issue at", err) 749 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 return 751 } 752 753 - rp.notifier.NewIssue(r.Context(), issue) 754 755 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 756 return 757 } 758 }
··· 1 package issues 2 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 "fmt" 8 "log" 9 + "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 31 "tangled.sh/tangled.sh/core/tid" 32 ) 33 ··· 39 db *db.DB 40 config *config.Config 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 44 } 45 46 func New( ··· 51 db *db.DB, 52 config *config.Config, 53 notifier notify.Notifier, 54 + validator *validator.Validator, 55 ) *Issues { 56 return &Issues{ 57 oauth: oauth, ··· 61 db: db, 62 config: config, 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 66 } 67 } 68 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { ··· 75 return 76 } 77 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 82 return 83 } 84 85 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 86 if err != nil { 87 + l.Error("failed to get issue reactions", "err", err) 88 } 89 90 userReactions := map[db.ReactionKind]bool{} 91 if user != nil { 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 94 95 + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 100 + OrderedReactionKinds: db.OrderedReactionKinds, 101 + Reactions: reactionCountMap, 102 + UserReacted: userReactions, 103 + }) 104 + } 105 + 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 108 + user := rp.oauth.GetUser(r) 109 + f, err := rp.repoResolver.Resolve(r) 110 if err != nil { 111 + log.Println("failed to get repo and knot", err) 112 + return 113 } 114 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 119 + return 120 } 121 + 122 + switch r.Method { 123 + case http.MethodGet: 124 + rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(user), 127 + Issue: issue, 128 + }) 129 + case http.MethodPost: 130 + noticeId := "issues" 131 + newIssue := issue 132 + newIssue.Title = r.FormValue("title") 133 + newIssue.Body = r.FormValue("body") 134 + 135 + if err := rp.validator.ValidateIssue(newIssue); err != nil { 136 + l.Error("validation error", "err", err) 137 + rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 138 + return 139 } 140 141 + newRecord := newIssue.AsRecord() 142 143 + // edit an atproto record 144 + client, err := rp.oauth.AuthorizedClient(r) 145 + if err != nil { 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 148 + return 149 + } 150 151 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 + if err != nil { 153 + l.Error("failed to get record", "err", err) 154 + rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 + return 156 + } 157 + 158 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 159 + Collection: tangled.RepoIssueNSID, 160 + Repo: user.Did, 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 163 + Record: &lexutil.LexiconTypeDecoder{ 164 + Val: &newRecord, 165 + }, 166 + }) 167 + if err != nil { 168 + l.Error("failed to edit record on PDS", "err", err) 169 + rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 170 + return 171 + } 172 + 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 175 + if err != nil { 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 + return 179 + } 180 + defer tx.Rollback() 181 + 182 + err = db.PutIssue(tx, newIssue) 183 + if err != nil { 184 + log.Println("failed to edit issue", err) 185 + rp.pages.Notice(w, "issues", "Failed to edit issue.") 186 + return 187 + } 188 + 189 + if err = tx.Commit(); err != nil { 190 + l.Error("failed to edit issue", "err", err) 191 + rp.pages.Notice(w, "issues", "Failed to cedit issue.") 192 + return 193 + } 194 195 + rp.pages.HxRefresh(w) 196 + } 197 } 198 199 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 + l := rp.logger.With("handler", "DeleteIssue") 201 + noticeId := "issue-actions-error" 202 + 203 user := rp.oauth.GetUser(r) 204 + 205 f, err := rp.repoResolver.Resolve(r) 206 if err != nil { 207 + l.Error("failed to get repo and knot", "err", err) 208 + return 209 + } 210 + 211 + issue, ok := r.Context().Value("issue").(*db.Issue) 212 + if !ok { 213 + l.Error("failed to get issue") 214 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 215 return 216 } 217 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 218 219 + // delete from PDS 220 + client, err := rp.oauth.AuthorizedClient(r) 221 if err != nil { 222 + log.Println("failed to get authorized client", err) 223 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 224 + return 225 + } 226 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 227 + Collection: tangled.RepoIssueNSID, 228 + Repo: issue.Did, 229 + Rkey: issue.Rkey, 230 + }) 231 + if err != nil { 232 + // TODO: transact this better 233 + l.Error("failed to delete issue from PDS", "err", err) 234 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 235 return 236 } 237 238 + // delete from db 239 + if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 240 + l.Error("failed to delete issue", "err", err) 241 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 242 + return 243 + } 244 + 245 + // return to all issues page 246 + rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 247 + } 248 + 249 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 250 + l := rp.logger.With("handler", "CloseIssue") 251 + user := rp.oauth.GetUser(r) 252 + f, err := rp.repoResolver.Resolve(r) 253 if err != nil { 254 + l.Error("failed to get repo and knot", "err", err) 255 + return 256 + } 257 + 258 + issue, ok := r.Context().Value("issue").(*db.Issue) 259 + if !ok { 260 + l.Error("failed to get issue") 261 + rp.pages.Error404(w) 262 return 263 } 264 ··· 269 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 270 return user.Did == collab.Did 271 }) 272 + isIssueOwner := user.Did == issue.Did 273 274 // TODO: make this more granular 275 if isIssueOwner || isCollaborator { 276 + err = db.CloseIssues( 277 + rp.db, 278 + db.FilterEq("id", issue.Id), 279 + ) 280 if err != nil { 281 log.Println("failed to close issue", err) 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 283 return 284 } 285 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 287 return 288 } else { 289 log.Println("user is not permitted to close issue") ··· 293 } 294 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 297 user := rp.oauth.GetUser(r) 298 f, err := rp.repoResolver.Resolve(r) 299 if err != nil { ··· 301 return 302 } 303 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 308 return 309 } 310 ··· 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 316 return user.Did == collab.Did 317 }) 318 + isIssueOwner := user.Did == issue.Did 319 320 if isCollaborator || isIssueOwner { 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 325 if err != nil { 326 log.Println("failed to reopen issue", err) 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 328 return 329 } 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 331 return 332 } else { 333 log.Println("user is not the owner of the repo") ··· 337 } 338 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 341 user := rp.oauth.GetUser(r) 342 f, err := rp.repoResolver.Resolve(r) 343 if err != nil { 344 + l.Error("failed to get repo and knot", "err", err) 345 return 346 } 347 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 352 return 353 } 354 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 360 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 366 367 + comment := db.IssueComment{ 368 + Did: user.Did, 369 + Rkey: tid.TID(), 370 + IssueAt: issue.AtUri().String(), 371 + ReplyTo: replyTo, 372 + Body: body, 373 + Created: time.Now(), 374 + } 375 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 376 + l.Error("failed to validate comment", "err", err) 377 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 378 return 379 } 380 + record := comment.AsRecord() 381 382 + client, err := rp.oauth.AuthorizedClient(r) 383 if err != nil { 384 + l.Error("failed to get authorized client", "err", err) 385 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 386 return 387 } 388 389 + // create a record first 390 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 391 + Collection: tangled.RepoIssueCommentNSID, 392 + Repo: comment.Did, 393 + Rkey: comment.Rkey, 394 + Record: &lexutil.LexiconTypeDecoder{ 395 + Val: &record, 396 + }, 397 + }) 398 if err != nil { 399 + l.Error("failed to create comment", "err", err) 400 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 401 return 402 } 403 + atUri := resp.Uri 404 + defer func() { 405 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 406 + l.Error("rollback failed", "err", err) 407 + } 408 + }() 409 410 + commentId, err := db.AddIssueComment(rp.db, comment) 411 if err != nil { 412 + l.Error("failed to create comment", "err", err) 413 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 414 return 415 } 416 417 + // reset atUri to make rollback a no-op 418 + atUri = "" 419 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 420 + } 421 + 422 + func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 424 + user := rp.oauth.GetUser(r) 425 + f, err := rp.repoResolver.Resolve(r) 426 if err != nil { 427 + l.Error("failed to get repo and knot", "err", err) 428 return 429 } 430 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 435 return 436 } 437 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 443 if err != nil { 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 446 return 447 } 448 + if len(comments) != 1 { 449 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 450 + http.Error(w, "invalid comment id", http.StatusBadRequest) 451 + return 452 } 453 + comment := comments[0] 454 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 456 LoggedInUser: user, 457 RepoInfo: f.RepoInfo(user), 458 Issue: issue, 459 + Comment: &comment, 460 }) 461 } 462 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 465 user := rp.oauth.GetUser(r) 466 f, err := rp.repoResolver.Resolve(r) 467 if err != nil { 468 + l.Error("failed to get repo and knot", "err", err) 469 return 470 } 471 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 476 return 477 } 478 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 484 if err != nil { 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 487 return 488 } 489 + if len(comments) != 1 { 490 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 491 + http.Error(w, "invalid comment id", http.StatusBadRequest) 492 return 493 } 494 + comment := comments[0] 495 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 499 return 500 } ··· 505 LoggedInUser: user, 506 RepoInfo: f.RepoInfo(user), 507 Issue: issue, 508 + Comment: &comment, 509 }) 510 case http.MethodPost: 511 // extract form value ··· 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 517 return 518 } 519 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 525 + 526 + _, err = db.AddIssueComment(rp.db, newComment) 527 if err != nil { 528 log.Println("failed to perferom update-description query", err) 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 531 } 532 533 // rkey is optional, it was introduced later 534 + if newComment.Rkey != "" { 535 // update the record on pds 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 537 if err != nil { 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 540 return 541 } 542 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 544 Collection: tangled.RepoIssueCommentNSID, 545 Repo: user.Did, 546 + Rkey: newComment.Rkey, 547 SwapRecord: ex.Cid, 548 Record: &lexutil.LexiconTypeDecoder{ 549 + Val: &record, 550 }, 551 }) 552 if err != nil { 553 + l.Error("failed to update record on PDS", "err", err) 554 } 555 } 556 557 // return new comment body with htmx 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 559 LoggedInUser: user, 560 RepoInfo: f.RepoInfo(user), 561 Issue: issue, 562 + Comment: &newComment, 563 }) 564 + } 565 + } 566 + 567 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 568 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 569 + user := rp.oauth.GetUser(r) 570 + f, err := rp.repoResolver.Resolve(r) 571 + if err != nil { 572 + l.Error("failed to get repo and knot", "err", err) 573 return 574 + } 575 576 + issue, ok := r.Context().Value("issue").(*db.Issue) 577 + if !ok { 578 + l.Error("failed to get issue") 579 + rp.pages.Error404(w) 580 + return 581 } 582 583 + commentId := chi.URLParam(r, "commentId") 584 + comments, err := db.GetIssueComments( 585 + rp.db, 586 + db.FilterEq("id", commentId), 587 + ) 588 + if err != nil { 589 + l.Error("failed to fetch comment", "id", commentId) 590 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 591 + return 592 + } 593 + if len(comments) != 1 { 594 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 595 + http.Error(w, "invalid comment id", http.StatusBadRequest) 596 + return 597 + } 598 + comment := comments[0] 599 + 600 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 601 + LoggedInUser: user, 602 + RepoInfo: f.RepoInfo(user), 603 + Issue: issue, 604 + Comment: &comment, 605 + }) 606 } 607 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 610 user := rp.oauth.GetUser(r) 611 f, err := rp.repoResolver.Resolve(r) 612 if err != nil { 613 + l.Error("failed to get repo and knot", "err", err) 614 + return 615 + } 616 + 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 621 return 622 } 623 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 629 if err != nil { 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 632 + return 633 + } 634 + if len(comments) != 1 { 635 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 636 + http.Error(w, "invalid comment id", http.StatusBadRequest) 637 return 638 } 639 + comment := comments[0] 640 641 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 642 + LoggedInUser: user, 643 + RepoInfo: f.RepoInfo(user), 644 + Issue: issue, 645 + Comment: &comment, 646 + }) 647 + } 648 + 649 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 650 + l := rp.logger.With("handler", "DeleteIssueComment") 651 + user := rp.oauth.GetUser(r) 652 + f, err := rp.repoResolver.Resolve(r) 653 if err != nil { 654 + l.Error("failed to get repo and knot", "err", err) 655 return 656 } 657 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 662 return 663 } 664 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 670 if err != nil { 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 673 return 674 } 675 + if len(comments) != 1 { 676 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 677 + http.Error(w, "invalid comment id", http.StatusBadRequest) 678 + return 679 + } 680 + comment := comments[0] 681 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 685 return 686 } ··· 692 693 // optimistic deletion 694 deleted := time.Now() 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 696 if err != nil { 697 + l.Error("failed to delete comment", "err", err) 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 699 return 700 } ··· 708 return 709 } 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 711 + Collection: tangled.RepoIssueCommentNSID, 712 Repo: user.Did, 713 Rkey: comment.Rkey, 714 }) ··· 718 } 719 720 // optimistic update for htmx 721 comment.Body = "" 722 comment.Deleted = &deleted 723 724 // htmx fragment of comment after deletion 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 726 LoggedInUser: user, 727 RepoInfo: f.RepoInfo(user), 728 Issue: issue, 729 + Comment: &comment, 730 }) 731 } 732 733 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 756 return 757 } 758 759 + openVal := 0 760 + if isOpen { 761 + openVal = 1 762 + } 763 + issues, err := db.GetIssuesPaginated( 764 + rp.db, 765 + page, 766 + db.FilterEq("repo_at", f.RepoAt()), 767 + db.FilterEq("open", openVal), 768 + ) 769 if err != nil { 770 log.Println("failed to get issues", err) 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 772 return 773 } 774 775 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 776 LoggedInUser: rp.oauth.GetUser(r), 777 RepoInfo: f.RepoInfo(user), 778 Issues: issues, 779 FilteringByOpen: isOpen, 780 Page: page, 781 }) 782 } 783 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 786 user := rp.oauth.GetUser(r) 787 788 f, err := rp.repoResolver.Resolve(r) 789 if err != nil { 790 + l.Error("failed to get repo and knot", "err", err) 791 return 792 } 793 ··· 798 RepoInfo: f.RepoInfo(user), 799 }) 800 case http.MethodPost: 801 + issue := &db.Issue{ 802 + RepoAt: f.RepoAt(), 803 + Rkey: tid.TID(), 804 + Title: r.FormValue("title"), 805 + Body: r.FormValue("body"), 806 + Did: user.Did, 807 + Created: time.Now(), 808 } 809 810 + if err := rp.validator.ValidateIssue(issue); err != nil { 811 + l.Error("validation error", "err", err) 812 + rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 813 return 814 } 815 816 + record := issue.AsRecord() 817 818 + // create an atproto record 819 client, err := rp.oauth.AuthorizedClient(r) 820 if err != nil { 821 + l.Error("failed to get authorized client", "err", err) 822 rp.pages.Notice(w, "issues", "Failed to create issue.") 823 return 824 } 825 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 826 Collection: tangled.RepoIssueNSID, 827 Repo: user.Did, 828 + Rkey: issue.Rkey, 829 Record: &lexutil.LexiconTypeDecoder{ 830 + Val: &record, 831 }, 832 }) 833 if err != nil { 834 + l.Error("failed to create issue", "err", err) 835 rp.pages.Notice(w, "issues", "Failed to create issue.") 836 return 837 } 838 + atUri := resp.Uri 839 840 + tx, err := rp.db.BeginTx(r.Context(), nil) 841 if err != nil { 842 + rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 843 + return 844 + } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 848 + 849 + if errors.Is(err1, sql.ErrTxDone) { 850 + err1 = nil 851 + } 852 + 853 + if err := errors.Join(err1, err2); err != nil { 854 + l.Error("failed to rollback txn", "err", err) 855 + } 856 + } 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 860 + if err != nil { 861 + log.Println("failed to create issue", err) 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 863 return 864 } 865 866 + if err = tx.Commit(); err != nil { 867 + log.Println("failed to create issue", err) 868 + rp.pages.Notice(w, "issues", "Failed to create issue.") 869 + return 870 + } 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 874 + rp.notifier.NewIssue(r.Context(), issue) 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 876 return 877 } 878 } 879 + 880 + // this is used to rollback changes made to the PDS 881 + // 882 + // it is a no-op if the provided ATURI is empty 883 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 884 + if aturi == "" { 885 + return nil 886 + } 887 + 888 + parsed := syntax.ATURI(aturi) 889 + 890 + collection := parsed.Collection().String() 891 + repo := parsed.Authority().String() 892 + rkey := parsed.RecordKey().String() 893 + 894 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 895 + Collection: collection, 896 + Repo: repo, 897 + Rkey: rkey, 898 + }) 899 + return err 900 + }
+24 -10
appview/issues/router.go
··· 12 13 r.Route("/", func(r chi.Router) { 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 16 17 r.Group(func(r chi.Router) { 18 r.Use(middleware.AuthMiddleware(i.oauth)) 19 r.Get("/new", i.NewIssue) 20 r.Post("/new", i.NewIssue) 21 - r.Post("/{issue}/comment", i.NewIssueComment) 22 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 - r.Get("/", i.IssueComment) 24 - r.Delete("/", i.DeleteIssueComment) 25 - r.Get("/edit", i.EditIssueComment) 26 - r.Post("/edit", i.EditIssueComment) 27 - }) 28 - r.Post("/{issue}/close", i.CloseIssue) 29 - r.Post("/{issue}/reopen", i.ReopenIssue) 30 }) 31 }) 32
··· 12 13 r.Route("/", func(r chi.Router) { 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue()) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 21 + r.Group(func(r chi.Router) { 22 + r.Use(middleware.AuthMiddleware(i.oauth)) 23 + r.Post("/comment", i.NewIssueComment) 24 + r.Route("/comment/{commentId}/", func(r chi.Router) { 25 + r.Get("/", i.IssueComment) 26 + r.Delete("/", i.DeleteIssueComment) 27 + r.Get("/edit", i.EditIssueComment) 28 + r.Post("/edit", i.EditIssueComment) 29 + r.Get("/reply", i.ReplyIssueComment) 30 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 + }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 + r.Post("/close", i.CloseIssue) 36 + r.Post("/reopen", i.ReopenIssue) 37 + }) 38 + }) 39 40 r.Group(func(r chi.Router) { 41 r.Use(middleware.AuthMiddleware(i.oauth)) 42 r.Get("/new", i.NewIssue) 43 r.Post("/new", i.NewIssue) 44 }) 45 }) 46
+415 -233
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" ··· 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 ··· 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{ ··· 77 }) 78 } 79 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 83 84 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 87 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 90 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 return 94 } 95 l = l.With("domain", domain) 96 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 100 } 101 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 103 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 return 107 } 108 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 110 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 113 return 114 } 115 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 121 }) 122 } 123 124 - // create a signed request and check if a node responds to that 125 - func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 - l := k.Logger.With("handler", "init") 127 user := k.OAuth.GetUser(r) 128 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 131 fail := func() { 132 k.Pages.Notice(w, noticeId, defaultErr) 133 } 134 135 - domain := chi.URLParam(r, "domain") 136 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 138 return 139 } 140 l = l.With("domain", domain) 141 142 - l.Info("checking domain") 143 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 145 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 147 fail() 148 return 149 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 153 return 154 } 155 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 157 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 159 fail() 160 return 161 } 162 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 164 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 166 fail() 167 return 168 } 169 170 - resp, err := client.Init(user.Did) 171 if err != nil { 172 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 - l.Error("failed to make init request", "err", err) 174 return 175 } 176 177 - if resp.StatusCode == http.StatusConflict { 178 - k.Pages.Notice(w, noticeId, "This knot is already registered") 179 - l.Error("knot already registered", "statuscode", resp.StatusCode) 180 return 181 } 182 183 - if resp.StatusCode != http.StatusNoContent { 184 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 - l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 186 return 187 } 188 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 192 if err != nil { 193 return 194 } 195 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 198 199 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 - l.Error("signature mismatch", "bytes", signatureBytes) 202 - return 203 } 204 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 206 - if err != nil { 207 - l.Error("failed to start tx", "err", err) 208 fail() 209 return 210 } 211 - defer func() { 212 - tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 217 - }() 218 219 - // mark as registered 220 - err = db.Register(tx, domain) 221 if err != nil { 222 - l.Error("failed to register domain", "err", err) 223 fail() 224 return 225 } 226 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 229 if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 231 fail() 232 return 233 } 234 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 237 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 239 fail() 240 return 241 } 242 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 245 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 fail() 248 return 249 } 250 251 err = tx.Commit() 252 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 254 fail() 255 return 256 } 257 258 err = k.Enforcer.E.SavePolicy() 259 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 262 return 263 } 264 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 270 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 274 } 275 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 278 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 280 } 281 282 domain := chi.URLParam(r, "domain") 283 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 285 return 286 } 287 l = l.With("domain", domain) 288 289 - user := k.OAuth.GetUser(r) 290 - l = l.With("did", user.Did) 291 - 292 - // dashboard is only available to owners 293 - ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 294 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 296 fail() 297 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 300 return 301 } 302 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 304 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 306 fail() 307 return 308 } 309 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 313 if err != nil { 314 - l.Error("failed to get members list", "err", err) 315 fail() 316 return 317 } 318 - } 319 320 - repos, err := db.GetRepos( 321 - k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 325 - ) 326 - if err != nil { 327 - l.Error("failed to get repos list", "err", err) 328 - fail() 329 - return 330 - } 331 - // convert to map 332 - repoByMember := make(map[string][]db.Repo) 333 - for _, r := range repos { 334 - repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 - } 336 337 - var didsToResolve []string 338 - for _, m := range members { 339 - didsToResolve = append(didsToResolve, m) 340 - } 341 - didsToResolve = append(didsToResolve, reg.ByDid) 342 - resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve) 343 - didHandleMap := make(map[string]string) 344 - for _, identity := range resolvedIds { 345 - if !identity.Handle.IsInvalidHandle() { 346 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 347 - } else { 348 - didHandleMap[identity.DID.String()] = identity.DID.String() 349 } 350 } 351 352 - k.Pages.Knot(w, pages.KnotParams{ 353 - LoggedInUser: user, 354 - DidHandleMap: didHandleMap, 355 - Registration: reg, 356 - Members: members, 357 - Repos: repoByMember, 358 - IsOwner: true, 359 - }) 360 - } 361 362 - // list members of domain, requires auth and requires owner status 363 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 - l := k.Logger.With("handler", "members") 365 - 366 - domain := chi.URLParam(r, "domain") 367 - if domain == "" { 368 - http.Error(w, "malformed url", http.StatusBadRequest) 369 return 370 } 371 - l = l.With("domain", domain) 372 373 - // list all members for this domain 374 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 375 if err != nil { 376 - w.Write([]byte("failed to fetch member list")) 377 return 378 } 379 380 - w.Write([]byte(strings.Join(memberDids, "\n"))) 381 } 382 383 - // add member to domain, requires auth and requires invite access 384 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 385 - l := k.Logger.With("handler", "members") 386 387 domain := chi.URLParam(r, "domain") 388 if domain == "" { 389 - http.Error(w, "malformed url", http.StatusBadRequest) 390 return 391 } 392 l = l.With("domain", domain) 393 394 - reg, err := db.RegistrationByDomain(k.Db, domain) 395 if err != nil { 396 - l.Error("failed to get registration by domain", "err", err) 397 - http.Error(w, "malformed url", http.StatusBadRequest) 398 return 399 } 400 401 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 402 - l = l.With("notice-id", noticeId) 403 defaultErr := "Failed to add member. Try again later." 404 fail := func() { 405 k.Pages.Notice(w, noticeId, defaultErr) 406 } 407 408 - subjectIdentifier := r.FormValue("subject") 409 - if subjectIdentifier == "" { 410 - http.Error(w, "malformed form", http.StatusBadRequest) 411 return 412 } 413 - l = l.With("subjectIdentifier", subjectIdentifier) 414 415 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 416 if err != nil { 417 - l.Error("failed to resolve identity", "err", err) 418 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 419 return 420 } 421 - l = l.With("subjectDid", subjectIdentity.DID) 422 - 423 - l.Info("adding member to knot") 424 425 - // announce this relation into the firehose, store into owners' pds 426 client, err := k.OAuth.AuthorizedClient(r) 427 if err != nil { 428 - l.Error("failed to create client", "err", err) 429 fail() 430 return 431 } 432 433 - currentUser := k.OAuth.GetUser(r) 434 - createdAt := time.Now().Format(time.RFC3339) 435 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 Collection: tangled.KnotMemberNSID, 437 - Repo: currentUser.Did, 438 - Rkey: tid.TID(), 439 Record: &lexutil.LexiconTypeDecoder{ 440 Val: &tangled.KnotMember{ 441 - Subject: subjectIdentity.DID.String(), 442 Domain: domain, 443 - CreatedAt: createdAt, 444 - }}, 445 }) 446 - // invalid record 447 if err != nil { 448 - l.Error("failed to write to PDS", "err", err) 449 - fail() 450 return 451 } 452 - l = l.With("at-uri", resp.Uri) 453 - l.Info("wrote record to PDS") 454 455 - secret, err := db.GetRegistrationKey(k.Db, domain) 456 if err != nil { 457 - l.Error("failed to get registration key", "err", err) 458 fail() 459 return 460 } 461 462 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 463 if err != nil { 464 - l.Error("failed to create client", "err", err) 465 fail() 466 return 467 } 468 469 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 470 if err != nil { 471 - l.Error("failed to reach knotserver", "err", err) 472 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 473 return 474 } 475 476 - if ksResp.StatusCode != http.StatusNoContent { 477 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 478 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 479 return 480 } 481 482 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 483 if err != nil { 484 - l.Error("failed to add member to enforcer", "err", err) 485 fail() 486 return 487 } 488 489 - // success 490 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 491 - } 492 493 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 494 }
··· 1 package knots 2 3 import ( 4 + "errors" 5 "fmt" 6 "log/slog" 7 "net/http" 8 + "slices" 9 "time" 10 11 "github.com/go-chi/chi/v5" ··· 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/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 "tangled.sh/tangled.sh/core/idresolver" 22 "tangled.sh/tangled.sh/core/rbac" 23 "tangled.sh/tangled.sh/core/tid" 24 ··· 37 Knotstream *eventconsumer.Consumer 38 } 39 40 + func (k *Knots) Router() http.Handler { 41 r := chi.NewRouter() 42 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 45 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 48 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 53 return r 54 } 55 56 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 57 user := k.OAuth.GetUser(r) 58 + registrations, err := db.GetRegistrations( 59 + k.Db, 60 + db.FilterEq("did", user.Did), 61 + ) 62 if err != nil { 63 + k.Logger.Error("failed to fetch knot registrations", "err", err) 64 + w.WriteHeader(http.StatusInternalServerError) 65 + return 66 } 67 68 k.Pages.Knots(w, pages.KnotsParams{ ··· 71 }) 72 } 73 74 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 75 + l := k.Logger.With("handler", "dashboard") 76 77 user := k.OAuth.GetUser(r) 78 + l = l.With("user", user.Did) 79 80 + domain := chi.URLParam(r, "domain") 81 if domain == "" { 82 return 83 } 84 l = l.With("domain", domain) 85 86 + registrations, err := db.GetRegistrations( 87 + k.Db, 88 + db.FilterEq("did", user.Did), 89 + db.FilterEq("domain", domain), 90 + ) 91 + if err != nil { 92 + l.Error("failed to get registrations", "err", err) 93 + http.Error(w, "Not found", http.StatusNotFound) 94 + return 95 } 96 + if len(registrations) != 1 { 97 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 98 + return 99 + } 100 + registration := registrations[0] 101 102 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 if err != nil { 104 + l.Error("failed to get knot members", "err", err) 105 + http.Error(w, "Not found", http.StatusInternalServerError) 106 return 107 } 108 + slices.Sort(members) 109 110 + repos, err := db.GetRepos( 111 + k.Db, 112 + 0, 113 + db.FilterEq("knot", domain), 114 + ) 115 if err != nil { 116 + l.Error("failed to get knot repos", "err", err) 117 + http.Error(w, "Not found", http.StatusInternalServerError) 118 return 119 } 120 121 + // organize repos by did 122 + repoMap := make(map[string][]db.Repo) 123 + for _, r := range repos { 124 + repoMap[r.Did] = append(repoMap[r.Did], r) 125 + } 126 + 127 + k.Pages.Knot(w, pages.KnotParams{ 128 + LoggedInUser: user, 129 + Registration: &registration, 130 + Members: members, 131 + Repos: repoMap, 132 + IsOwner: true, 133 }) 134 } 135 136 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 137 user := k.OAuth.GetUser(r) 138 + l := k.Logger.With("handler", "register") 139 140 + noticeId := "register-error" 141 + defaultErr := "Failed to register knot. Try again later." 142 fail := func() { 143 k.Pages.Notice(w, noticeId, defaultErr) 144 } 145 146 + domain := r.FormValue("domain") 147 if domain == "" { 148 + k.Pages.Notice(w, noticeId, "Incomplete form.") 149 return 150 } 151 l = l.With("domain", domain) 152 + l = l.With("user", user.Did) 153 154 + tx, err := k.Db.Begin() 155 + if err != nil { 156 + l.Error("failed to start transaction", "err", err) 157 + fail() 158 + return 159 + } 160 + defer func() { 161 + tx.Rollback() 162 + k.Enforcer.E.LoadPolicy() 163 + }() 164 165 + err = db.AddKnot(tx, domain, user.Did) 166 if err != nil { 167 + l.Error("failed to insert", "err", err) 168 fail() 169 return 170 } 171 + 172 + err = k.Enforcer.AddKnot(domain) 173 + if err != nil { 174 + l.Error("failed to create knot", "err", err) 175 + fail() 176 return 177 } 178 179 + // create record on pds 180 + client, err := k.OAuth.AuthorizedClient(r) 181 if err != nil { 182 + l.Error("failed to authorize client", "err", err) 183 fail() 184 return 185 } 186 187 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + var exCid *string 189 + if ex != nil { 190 + exCid = ex.Cid 191 + } 192 + 193 + // re-announce by registering under same rkey 194 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + Collection: tangled.KnotNSID, 196 + Repo: user.Did, 197 + Rkey: domain, 198 + Record: &lexutil.LexiconTypeDecoder{ 199 + Val: &tangled.Knot{ 200 + CreatedAt: time.Now().Format(time.RFC3339), 201 + }, 202 + }, 203 + SwapRecord: exCid, 204 + }) 205 + 206 if err != nil { 207 + l.Error("failed to put record", "err", err) 208 fail() 209 return 210 } 211 212 + err = tx.Commit() 213 if err != nil { 214 + l.Error("failed to commit transaction", "err", err) 215 + fail() 216 return 217 } 218 219 + err = k.Enforcer.E.SavePolicy() 220 + if err != nil { 221 + l.Error("failed to update ACL", "err", err) 222 + k.Pages.HxRefresh(w) 223 return 224 } 225 226 + // begin verification 227 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 228 + if err != nil { 229 + l.Error("verification failed", "err", err) 230 + k.Pages.HxRefresh(w) 231 return 232 } 233 234 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 235 if err != nil { 236 + l.Error("failed to mark verified", "err", err) 237 + k.Pages.HxRefresh(w) 238 return 239 } 240 241 + // add this knot to knotstream 242 + go k.Knotstream.AddSource( 243 + r.Context(), 244 + eventconsumer.NewKnotSource(domain), 245 + ) 246 247 + // ok 248 + k.Pages.HxRefresh(w) 249 + } 250 + 251 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 252 + user := k.OAuth.GetUser(r) 253 + l := k.Logger.With("handler", "delete") 254 + 255 + noticeId := "operation-error" 256 + defaultErr := "Failed to delete knot. Try again later." 257 + fail := func() { 258 + k.Pages.Notice(w, noticeId, defaultErr) 259 } 260 261 + domain := chi.URLParam(r, "domain") 262 + if domain == "" { 263 + l.Error("empty domain") 264 fail() 265 return 266 } 267 268 + // get record from db first 269 + registrations, err := db.GetRegistrations( 270 + k.Db, 271 + db.FilterEq("did", user.Did), 272 + db.FilterEq("domain", domain), 273 + ) 274 if err != nil { 275 + l.Error("failed to get registration", "err", err) 276 + fail() 277 + return 278 + } 279 + if len(registrations) != 1 { 280 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 281 fail() 282 return 283 } 284 + registration := registrations[0] 285 286 + tx, err := k.Db.Begin() 287 if err != nil { 288 + l.Error("failed to start txn", "err", err) 289 fail() 290 return 291 } 292 + defer func() { 293 + tx.Rollback() 294 + k.Enforcer.E.LoadPolicy() 295 + }() 296 297 + err = db.DeleteKnot( 298 + tx, 299 + db.FilterEq("did", user.Did), 300 + db.FilterEq("domain", domain), 301 + ) 302 if err != nil { 303 + l.Error("failed to delete registration", "err", err) 304 fail() 305 return 306 } 307 308 + // delete from enforcer if it was registered 309 + if registration.Registered != nil { 310 + err = k.Enforcer.RemoveKnot(domain) 311 + if err != nil { 312 + l.Error("failed to update ACL", "err", err) 313 + fail() 314 + return 315 + } 316 + } 317 + 318 + client, err := k.OAuth.AuthorizedClient(r) 319 if err != nil { 320 + l.Error("failed to authorize client", "err", err) 321 fail() 322 return 323 } 324 325 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + Collection: tangled.KnotNSID, 327 + Repo: user.Did, 328 + Rkey: domain, 329 + }) 330 + if err != nil { 331 + // non-fatal 332 + l.Error("failed to delete record", "err", err) 333 + } 334 + 335 err = tx.Commit() 336 if err != nil { 337 + l.Error("failed to delete knot", "err", err) 338 fail() 339 return 340 } 341 342 err = k.Enforcer.E.SavePolicy() 343 if err != nil { 344 + l.Error("failed to update ACL", "err", err) 345 + k.Pages.HxRefresh(w) 346 return 347 } 348 349 + shouldRedirect := r.Header.Get("shouldRedirect") 350 + if shouldRedirect == "true" { 351 + k.Pages.HxRedirect(w, "/knots") 352 + return 353 + } 354 355 + w.Write([]byte{}) 356 } 357 358 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 359 + user := k.OAuth.GetUser(r) 360 + l := k.Logger.With("handler", "retry") 361 + 362 + noticeId := "operation-error" 363 + defaultErr := "Failed to verify knot. Try again later." 364 fail := func() { 365 + k.Pages.Notice(w, noticeId, defaultErr) 366 } 367 368 domain := chi.URLParam(r, "domain") 369 if domain == "" { 370 + l.Error("empty domain") 371 + fail() 372 return 373 } 374 l = l.With("domain", domain) 375 + l = l.With("user", user.Did) 376 377 + // get record from db first 378 + registrations, err := db.GetRegistrations( 379 + k.Db, 380 + db.FilterEq("did", user.Did), 381 + db.FilterEq("domain", domain), 382 + ) 383 if err != nil { 384 + l.Error("failed to get registration", "err", err) 385 fail() 386 + return 387 } 388 + if len(registrations) != 1 { 389 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 390 + fail() 391 return 392 } 393 + registration := registrations[0] 394 395 + // begin verification 396 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 397 if err != nil { 398 + l.Error("verification failed", "err", err) 399 + 400 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 401 + k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 402 + return 403 + } 404 + 405 + if e, ok := err.(*serververify.OwnerMismatch); ok { 406 + k.Pages.Notice(w, noticeId, e.Error()) 407 + return 408 + } 409 + 410 fail() 411 return 412 } 413 414 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 415 + if err != nil { 416 + l.Error("failed to mark verified", "err", err) 417 + k.Pages.Notice(w, noticeId, err.Error()) 418 + return 419 + } 420 + 421 + // if this knot requires upgrade, then emit a record too 422 + // 423 + // this is part of migrating from the old knot system to the new one 424 + if registration.NeedsUpgrade { 425 + // re-announce by registering under same rkey 426 + client, err := k.OAuth.AuthorizedClient(r) 427 if err != nil { 428 + l.Error("failed to authorize client", "err", err) 429 fail() 430 return 431 } 432 433 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + var exCid *string 435 + if ex != nil { 436 + exCid = ex.Cid 437 + } 438 439 + // ignore the error here 440 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + Collection: tangled.KnotNSID, 442 + Repo: user.Did, 443 + Rkey: domain, 444 + Record: &lexutil.LexiconTypeDecoder{ 445 + Val: &tangled.Knot{ 446 + CreatedAt: time.Now().Format(time.RFC3339), 447 + }, 448 + }, 449 + SwapRecord: exCid, 450 + }) 451 + if err != nil { 452 + l.Error("non-fatal: failed to reannouce knot", "err", err) 453 } 454 } 455 456 + // add this knot to knotstream 457 + go k.Knotstream.AddSource( 458 + r.Context(), 459 + eventconsumer.NewKnotSource(domain), 460 + ) 461 462 + shouldRefresh := r.Header.Get("shouldRefresh") 463 + if shouldRefresh == "true" { 464 + k.Pages.HxRefresh(w) 465 return 466 } 467 468 + // Get updated registration to show 469 + registrations, err = db.GetRegistrations( 470 + k.Db, 471 + db.FilterEq("did", user.Did), 472 + db.FilterEq("domain", domain), 473 + ) 474 if err != nil { 475 + l.Error("failed to get registration", "err", err) 476 + fail() 477 return 478 } 479 + if len(registrations) != 1 { 480 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 481 + fail() 482 + return 483 + } 484 + updatedRegistration := registrations[0] 485 486 + w.Header().Set("HX-Reswap", "outerHTML") 487 + k.Pages.KnotListing(w, pages.KnotListingParams{ 488 + Registration: &updatedRegistration, 489 + }) 490 } 491 492 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 493 + user := k.OAuth.GetUser(r) 494 + l := k.Logger.With("handler", "addMember") 495 496 domain := chi.URLParam(r, "domain") 497 if domain == "" { 498 + l.Error("empty domain") 499 + http.Error(w, "Not found", http.StatusNotFound) 500 return 501 } 502 l = l.With("domain", domain) 503 + l = l.With("user", user.Did) 504 505 + registrations, err := db.GetRegistrations( 506 + k.Db, 507 + db.FilterEq("did", user.Did), 508 + db.FilterEq("domain", domain), 509 + db.FilterIsNot("registered", "null"), 510 + ) 511 if err != nil { 512 + l.Error("failed to get registration", "err", err) 513 + return 514 + } 515 + if len(registrations) != 1 { 516 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 517 return 518 } 519 + registration := registrations[0] 520 521 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 522 defaultErr := "Failed to add member. Try again later." 523 fail := func() { 524 k.Pages.Notice(w, noticeId, defaultErr) 525 } 526 527 + member := r.FormValue("member") 528 + if member == "" { 529 + l.Error("empty member") 530 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 531 return 532 } 533 + l = l.With("member", member) 534 535 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 536 if err != nil { 537 + l.Error("failed to resolve member identity to handle", "err", err) 538 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 539 return 540 } 541 + if memberId.Handle.IsInvalidHandle() { 542 + l.Error("failed to resolve member identity to handle") 543 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 544 + return 545 + } 546 547 + // write to pds 548 client, err := k.OAuth.AuthorizedClient(r) 549 if err != nil { 550 + l.Error("failed to authorize client", "err", err) 551 fail() 552 return 553 } 554 555 + rkey := tid.TID() 556 + 557 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 Collection: tangled.KnotMemberNSID, 559 + Repo: user.Did, 560 + Rkey: rkey, 561 Record: &lexutil.LexiconTypeDecoder{ 562 Val: &tangled.KnotMember{ 563 + CreatedAt: time.Now().Format(time.RFC3339), 564 Domain: domain, 565 + Subject: memberId.DID.String(), 566 + }, 567 + }, 568 }) 569 if err != nil { 570 + l.Error("failed to add record to PDS", "err", err) 571 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 572 return 573 } 574 575 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 576 if err != nil { 577 + l.Error("failed to add member to ACLs", "err", err) 578 fail() 579 return 580 } 581 582 + err = k.Enforcer.E.SavePolicy() 583 if err != nil { 584 + l.Error("failed to save ACL policy", "err", err) 585 fail() 586 return 587 } 588 589 + // success 590 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 591 + } 592 + 593 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 594 + user := k.OAuth.GetUser(r) 595 + l := k.Logger.With("handler", "removeMember") 596 + 597 + noticeId := "operation-error" 598 + defaultErr := "Failed to remove member. Try again later." 599 + fail := func() { 600 + k.Pages.Notice(w, noticeId, defaultErr) 601 + } 602 + 603 + domain := chi.URLParam(r, "domain") 604 + if domain == "" { 605 + l.Error("empty domain") 606 + fail() 607 + return 608 + } 609 + l = l.With("domain", domain) 610 + l = l.With("user", user.Did) 611 + 612 + registrations, err := db.GetRegistrations( 613 + k.Db, 614 + db.FilterEq("did", user.Did), 615 + db.FilterEq("domain", domain), 616 + db.FilterIsNot("registered", "null"), 617 + ) 618 if err != nil { 619 + l.Error("failed to get registration", "err", err) 620 + return 621 + } 622 + if len(registrations) != 1 { 623 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 624 + return 625 + } 626 + 627 + member := r.FormValue("member") 628 + if member == "" { 629 + l.Error("empty member") 630 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 631 + return 632 + } 633 + l = l.With("member", member) 634 + 635 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 636 + if err != nil { 637 + l.Error("failed to resolve member identity to handle", "err", err) 638 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 639 + return 640 + } 641 + if memberId.Handle.IsInvalidHandle() { 642 + l.Error("failed to resolve member identity to handle") 643 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 644 return 645 } 646 647 + // remove from enforcer 648 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 649 + if err != nil { 650 + l.Error("failed to update ACLs", "err", err) 651 + fail() 652 return 653 } 654 655 + client, err := k.OAuth.AuthorizedClient(r) 656 if err != nil { 657 + l.Error("failed to authorize client", "err", err) 658 fail() 659 return 660 } 661 662 + // TODO: We need to track the rkey for knot members to delete the record 663 + // For now, just remove from ACLs 664 + _ = client 665 666 + // commit everything 667 + err = k.Enforcer.E.SavePolicy() 668 + if err != nil { 669 + l.Error("failed to save ACLs", "err", err) 670 + fail() 671 + return 672 + } 673 + 674 + // ok 675 + k.Pages.HxRefresh(w) 676 }
+59 -24
appview/middleware/middleware.go
··· 5 "fmt" 6 "log" 7 "net/http" 8 "slices" 9 "strconv" 10 "strings" 11 - "time" 12 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" ··· 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 50 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 51 } 52 if r.Header.Get("HX-Request") == "true" { 53 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 54 - w.Header().Set("HX-Redirect", "/login") 55 w.WriteHeader(http.StatusOK) 56 } 57 } ··· 167 } 168 } 169 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 func (mw Middleware) ResolveIdent() middlewareFunc { 181 excluded := []string{"favicon.ico"} 182 ··· 188 return 189 } 190 191 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 if err != nil { 193 // invalid did or handle 194 - log.Println("failed to resolve did/handle:", err) 195 mw.pages.Error404(w) 196 return 197 } ··· 218 if err != nil { 219 // invalid did or handle 220 log.Println("failed to resolve repo") 221 - mw.pages.Error404(w) 222 return 223 } 224 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)) 230 next.ServeHTTP(w, req.WithContext(ctx)) 231 }) 232 } ··· 239 f, err := mw.repoResolver.Resolve(r) 240 if err != nil { 241 log.Println("failed to fully resolve repo", err) 242 - http.Error(w, "invalid repo url", http.StatusNotFound) 243 return 244 } 245 ··· 251 return 252 } 253 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 255 if err != nil { 256 log.Println("failed to get pull and comments", err) 257 return ··· 280 } 281 } 282 283 // this should serve the go-import meta tag even if the path is technically 284 // a 404 like tangled.sh/oppi.li/go-git/v5 285 func (mw Middleware) GoImport() middlewareFunc { ··· 288 f, err := mw.repoResolver.Resolve(r) 289 if err != nil { 290 log.Println("failed to fully resolve repo", err) 291 - http.Error(w, "invalid repo url", http.StatusNotFound) 292 return 293 } 294 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 296 297 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 if r.URL.Query().Get("go-get") == "1" {
··· 5 "fmt" 6 "log" 7 "net/http" 8 + "net/url" 9 "slices" 10 "strconv" 11 "strings" 12 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" ··· 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 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 + 56 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 57 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 58 } 59 if r.Header.Get("HX-Request") == "true" { 60 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 61 + w.Header().Set("HX-Redirect", loginURL) 62 w.WriteHeader(http.StatusOK) 63 } 64 } ··· 174 } 175 } 176 177 func (mw Middleware) ResolveIdent() middlewareFunc { 178 excluded := []string{"favicon.ico"} 179 ··· 185 return 186 } 187 188 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 + 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 if err != nil { 192 // invalid did or handle 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 194 mw.pages.Error404(w) 195 return 196 } ··· 217 if err != nil { 218 // invalid did or handle 219 log.Println("failed to resolve repo") 220 + mw.pages.ErrorKnot404(w) 221 return 222 } 223 224 + ctx := context.WithValue(req.Context(), "repo", repo) 225 next.ServeHTTP(w, req.WithContext(ctx)) 226 }) 227 } ··· 234 f, err := mw.repoResolver.Resolve(r) 235 if err != nil { 236 log.Println("failed to fully resolve repo", err) 237 + mw.pages.ErrorKnot404(w) 238 return 239 } 240 ··· 246 return 247 } 248 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 250 if err != nil { 251 log.Println("failed to get pull and comments", err) 252 return ··· 275 } 276 } 277 278 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 + func (mw Middleware) ResolveIssue() middlewareFunc { 280 + return func(next http.Handler) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 + f, err := mw.repoResolver.Resolve(r) 283 + if err != nil { 284 + log.Println("failed to fully resolve repo", err) 285 + mw.pages.ErrorKnot404(w) 286 + return 287 + } 288 + 289 + issueIdStr := chi.URLParam(r, "issue") 290 + issueId, err := strconv.Atoi(issueIdStr) 291 + if err != nil { 292 + log.Println("failed to fully resolve issue ID", err) 293 + mw.pages.ErrorKnot404(w) 294 + return 295 + } 296 + 297 + issues, err := db.GetIssues( 298 + mw.db, 299 + db.FilterEq("repo_at", f.RepoAt()), 300 + db.FilterEq("issue_id", issueId), 301 + ) 302 + if err != nil { 303 + log.Println("failed to get issues", "err", err) 304 + return 305 + } 306 + if len(issues) != 1 { 307 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 + return 309 + } 310 + issue := issues[0] 311 + 312 + ctx := context.WithValue(r.Context(), "issue", &issue) 313 + next.ServeHTTP(w, r.WithContext(ctx)) 314 + }) 315 + } 316 + } 317 + 318 // this should serve the go-import meta tag even if the path is technically 319 // a 404 like tangled.sh/oppi.li/go-git/v5 320 func (mw Middleware) GoImport() middlewareFunc { ··· 323 f, err := mw.repoResolver.Resolve(r) 324 if err != nil { 325 log.Println("failed to fully resolve repo", err) 326 + mw.pages.ErrorKnot404(w) 327 return 328 } 329 330 + fullName := f.OwnerHandle() + "/" + f.Name 331 332 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 333 if r.URL.Query().Get("go-get") == "1" {
+18
appview/notify/merged_notifier.go
··· 66 notifier.UpdateProfile(ctx, profile) 67 } 68 }
··· 66 notifier.UpdateProfile(ctx, profile) 67 } 68 } 69 + 70 + func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) { 71 + for _, notifier := range m.notifiers { 72 + notifier.NewString(ctx, string) 73 + } 74 + } 75 + 76 + func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) { 77 + for _, notifier := range m.notifiers { 78 + notifier.EditString(ctx, string) 79 + } 80 + } 81 + 82 + func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 + for _, notifier := range m.notifiers { 84 + notifier.DeleteString(ctx, did, rkey) 85 + } 86 + }
+8
appview/notify/notifier.go
··· 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 ··· 42 func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 43 44 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
··· 21 NewPullComment(ctx context.Context, comment *db.PullComment) 22 23 UpdateProfile(ctx context.Context, profile *db.Profile) 24 + 25 + NewString(ctx context.Context, s *db.String) 26 + EditString(ctx context.Context, s *db.String) 27 + DeleteString(ctx context.Context, did, rkey string) 28 } 29 30 // BaseNotifier is a listener that does nothing ··· 46 func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 47 48 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 49 + 50 + func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {} 51 + func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {} 52 + func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+196 -17
appview/oauth/handler/handler.go
··· 1 package oauth 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "log" 7 "net/http" 8 "net/url" 9 "strings" 10 11 "github.com/go-chi/chi/v5" 12 "github.com/gorilla/sessions" 13 "github.com/lestrrat-go/jwx/v2/jwk" 14 "github.com/posthog/posthog-go" 15 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 16 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" ··· 21 "tangled.sh/tangled.sh/core/appview/oauth/client" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) 27 28 const ( ··· 104 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 105 switch r.Method { 106 case http.MethodGet: 107 - o.pages.Login(w, pages.LoginParams{}) 108 case http.MethodPost: 109 handle := r.FormValue("handle") 110 ··· 189 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 190 DpopPrivateJwk: string(dpopKeyJson), 191 State: parResp.State, 192 }) 193 if err != nil { 194 log.Println("failed to save oauth request:", err) ··· 244 return 245 } 246 247 self := o.oauth.ClientMetadata() 248 249 oauthClient, err := client.NewClient( ··· 294 295 log.Println("session saved successfully") 296 go o.addToDefaultKnot(oauthRequest.Did) 297 298 if !o.config.Core.Dev { 299 err = o.posthog.Enqueue(posthog.Capture{ ··· 305 } 306 } 307 308 - http.Redirect(w, r, "/", http.StatusFound) 309 } 310 311 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 332 return pubKey, nil 333 } 334 335 - func (o *OAuthHandler) addToDefaultKnot(did string) { 336 - defaultKnot := "knot1.tangled.sh" 337 338 - log.Printf("adding %s to default knot", did) 339 - err := o.enforcer.AddKnotMember(defaultKnot, did) 340 if err != nil { 341 - log.Println("failed to add user to knot1.tangled.sh: ", err) 342 return 343 } 344 - err = o.enforcer.E.SavePolicy() 345 if err != nil { 346 - log.Println("failed to add user to knot1.tangled.sh: ", err) 347 return 348 } 349 350 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 351 if err != nil { 352 - log.Println("failed to get registration key for knot1.tangled.sh") 353 return 354 } 355 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 356 - resp, err := signedClient.AddMember(did) 357 if err != nil { 358 - log.Println("failed to add user to knot1.tangled.sh: ", err) 359 return 360 } 361 362 - if resp.StatusCode != http.StatusNoContent { 363 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 364 return 365 } 366 }
··· 1 package oauth 2 3 import ( 4 + "bytes" 5 + "context" 6 "encoding/json" 7 "fmt" 8 "log" 9 "net/http" 10 "net/url" 11 + "slices" 12 "strings" 13 + "time" 14 15 "github.com/go-chi/chi/v5" 16 "github.com/gorilla/sessions" 17 "github.com/lestrrat-go/jwx/v2/jwk" 18 "github.com/posthog/posthog-go" 19 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 20 + tangled "tangled.sh/tangled.sh/core/api/tangled" 21 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 22 "tangled.sh/tangled.sh/core/appview/config" 23 "tangled.sh/tangled.sh/core/appview/db" ··· 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 27 "tangled.sh/tangled.sh/core/appview/pages" 28 "tangled.sh/tangled.sh/core/idresolver" 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 31 ) 32 33 const ( ··· 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 110 switch r.Method { 111 case http.MethodGet: 112 + returnURL := r.URL.Query().Get("return_url") 113 + o.pages.Login(w, pages.LoginParams{ 114 + ReturnUrl: returnURL, 115 + }) 116 case http.MethodPost: 117 handle := r.FormValue("handle") 118 ··· 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 198 DpopPrivateJwk: string(dpopKeyJson), 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 201 }) 202 if err != nil { 203 log.Println("failed to save oauth request:", err) ··· 253 return 254 } 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 + 262 self := o.oauth.ClientMetadata() 263 264 oauthClient, err := client.NewClient( ··· 309 310 log.Println("session saved successfully") 311 go o.addToDefaultKnot(oauthRequest.Did) 312 + go o.addToDefaultSpindle(oauthRequest.Did) 313 314 if !o.config.Core.Dev { 315 err = o.posthog.Enqueue(posthog.Capture{ ··· 321 } 322 } 323 324 + returnUrl := oauthRequest.ReturnUrl 325 + if returnUrl == "" { 326 + returnUrl = "/" 327 + } 328 + 329 + http.Redirect(w, r, returnUrl, http.StatusFound) 330 } 331 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 353 return pubKey, nil 354 } 355 356 + var ( 357 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 + icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 359 360 + defaultSpindle = "spindle.tangled.sh" 361 + defaultKnot = "knot1.tangled.sh" 362 + ) 363 + 364 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 365 + // use the tangled.sh app password to get an accessJwt 366 + // and create an sh.tangled.spindle.member record with that 367 + spindleMembers, err := db.GetSpindleMembers( 368 + o.db, 369 + db.FilterEq("instance", "spindle.tangled.sh"), 370 + db.FilterEq("subject", did), 371 + ) 372 if err != nil { 373 + log.Printf("failed to get spindle members for did %s: %v", did, err) 374 + return 375 + } 376 + 377 + if len(spindleMembers) != 0 { 378 + log.Printf("did %s is already a member of the default spindle", did) 379 return 380 } 381 + 382 + log.Printf("adding %s to default spindle", did) 383 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 384 if err != nil { 385 + log.Printf("failed to create session: %s", err) 386 + return 387 + } 388 + 389 + record := tangled.SpindleMember{ 390 + LexiconTypeID: "sh.tangled.spindle.member", 391 + Subject: did, 392 + Instance: defaultSpindle, 393 + CreatedAt: time.Now().Format(time.RFC3339), 394 + } 395 + 396 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 397 + log.Printf("failed to add member to default spindle: %s", err) 398 return 399 } 400 401 + log.Printf("successfully added %s to default spindle", did) 402 + } 403 + 404 + func (o *OAuthHandler) addToDefaultKnot(did string) { 405 + // use the tangled.sh app password to get an accessJwt 406 + // and create an sh.tangled.spindle.member record with that 407 + 408 + allKnots, err := o.enforcer.GetKnotsForUser(did) 409 if err != nil { 410 + log.Printf("failed to get knot members for did %s: %v", did, err) 411 + return 412 + } 413 + 414 + if slices.Contains(allKnots, defaultKnot) { 415 + log.Printf("did %s is already a member of the default knot", did) 416 return 417 } 418 + 419 + log.Printf("adding %s to default knot", did) 420 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 421 if err != nil { 422 + log.Printf("failed to create session: %s", err) 423 return 424 } 425 426 + record := tangled.KnotMember{ 427 + LexiconTypeID: "sh.tangled.knot.member", 428 + Subject: did, 429 + Domain: defaultKnot, 430 + CreatedAt: time.Now().Format(time.RFC3339), 431 + } 432 + 433 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 434 + log.Printf("failed to add member to default knot: %s", err) 435 + return 436 + } 437 + 438 + if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil { 439 + log.Printf("failed to set up enforcer rules: %s", err) 440 return 441 } 442 + 443 + log.Printf("successfully added %s to default Knot", did) 444 + } 445 + 446 + // create a session using apppasswords 447 + type session struct { 448 + AccessJwt string `json:"accessJwt"` 449 + PdsEndpoint string 450 + Did string 451 + } 452 + 453 + func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 454 + if appPassword == "" { 455 + return nil, fmt.Errorf("no app password configured, skipping member addition") 456 + } 457 + 458 + resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 459 + if err != nil { 460 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 461 + } 462 + 463 + pdsEndpoint := resolved.PDSEndpoint() 464 + if pdsEndpoint == "" { 465 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 466 + } 467 + 468 + sessionPayload := map[string]string{ 469 + "identifier": did, 470 + "password": appPassword, 471 + } 472 + sessionBytes, err := json.Marshal(sessionPayload) 473 + if err != nil { 474 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 475 + } 476 + 477 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 478 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 479 + if err != nil { 480 + return nil, fmt.Errorf("failed to create session request: %v", err) 481 + } 482 + sessionReq.Header.Set("Content-Type", "application/json") 483 + 484 + client := &http.Client{Timeout: 30 * time.Second} 485 + sessionResp, err := client.Do(sessionReq) 486 + if err != nil { 487 + return nil, fmt.Errorf("failed to create session: %v", err) 488 + } 489 + defer sessionResp.Body.Close() 490 + 491 + if sessionResp.StatusCode != http.StatusOK { 492 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 493 + } 494 + 495 + var session session 496 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 497 + return nil, fmt.Errorf("failed to decode session response: %v", err) 498 + } 499 + 500 + session.PdsEndpoint = pdsEndpoint 501 + session.Did = did 502 + 503 + return &session, nil 504 + } 505 + 506 + func (s *session) putRecord(record any, collection string) error { 507 + recordBytes, err := json.Marshal(record) 508 + if err != nil { 509 + return fmt.Errorf("failed to marshal knot member record: %w", err) 510 + } 511 + 512 + payload := map[string]any{ 513 + "repo": s.Did, 514 + "collection": collection, 515 + "rkey": tid.TID(), 516 + "record": json.RawMessage(recordBytes), 517 + } 518 + 519 + payloadBytes, err := json.Marshal(payload) 520 + if err != nil { 521 + return fmt.Errorf("failed to marshal request payload: %w", err) 522 + } 523 + 524 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 525 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 526 + if err != nil { 527 + return fmt.Errorf("failed to create HTTP request: %w", err) 528 + } 529 + 530 + req.Header.Set("Content-Type", "application/json") 531 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 532 + 533 + client := &http.Client{Timeout: 30 * time.Second} 534 + resp, err := client.Do(req) 535 + if err != nil { 536 + return fmt.Errorf("failed to add user to default service: %w", err) 537 + } 538 + defer resp.Body.Close() 539 + 540 + if resp.StatusCode != http.StatusOK { 541 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 542 + } 543 + 544 + return nil 545 }
+16 -3
appview/oauth/oauth.go
··· 103 if err != nil { 104 return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 106 - if expiry.Sub(time.Now()) <= 5*time.Minute { 107 privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 if err != nil { 109 return nil, false, err ··· 224 s.service = service 225 } 226 } 227 func WithExp(exp int64) ServiceClientOpt { 228 return func(s *ServiceClientOpts) { 229 - s.exp = exp 230 } 231 } 232 ··· 266 return nil, err 267 } 268 269 resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 270 if err != nil { 271 return nil, err ··· 276 AccessJwt: resp.Token, 277 }, 278 Host: opts.Host(), 279 }, nil 280 } 281 ··· 305 redirectURIs := makeRedirectURIs(clientURI) 306 307 if o.config.Core.Dev { 308 - clientURI = fmt.Sprintf("http://127.0.0.1:3000") 309 redirectURIs = makeRedirectURIs(clientURI) 310 311 query := url.Values{}
··· 103 if err != nil { 104 return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 106 + if time.Until(expiry) <= 5*time.Minute { 107 privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 if err != nil { 109 return nil, false, err ··· 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 ··· 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 ··· 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), 289 + Client: &http.Client{ 290 + Timeout: time.Second * 5, 291 + }, 292 }, nil 293 } 294 ··· 318 redirectURIs := makeRedirectURIs(clientURI) 319 320 if o.config.Core.Dev { 321 + clientURI = "http://127.0.0.1:3000" 322 redirectURIs = makeRedirectURIs(clientURI) 323 324 query := url.Values{}
+35
appview/pages/cache.go
···
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+45 -6
appview/pages/funcmap.go
··· 1 package pages 2 3 import ( 4 "crypto/hmac" 5 "crypto/sha256" 6 "encoding/hex" ··· 18 19 "github.com/dustin/go-humanize" 20 "github.com/go-enry/go-enry/v2" 21 - "github.com/microcosm-cc/bluemonday" 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 ) 25 26 func (p *Pages) funcMap() template.FuncMap { ··· 28 "split": func(s string) []string { 29 return strings.Split(s, "\n") 30 }, 31 "truncateAt30": func(s string) string { 32 if len(s) <= 30 { 33 return s ··· 74 "negf64": func(a float64) float64 { 75 return -a 76 }, 77 - "cond": func(cond interface{}, a, b string) string { 78 if cond == nil { 79 return b 80 } ··· 167 return html.UnescapeString(s) 168 }, 169 "nl2br": func(text string) template.HTML { 170 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 171 }, 172 "unwrapText": func(text string) string { 173 paragraphs := strings.Split(text, "\n\n") ··· 193 } 194 return v.Slice(0, min(n, v.Len())).Interface() 195 }, 196 - 197 "markdown": func(text string) template.HTML { 198 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 199 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 200 }, 201 "isNil": func(t any) bool { 202 // returns false for other "zero" values ··· 236 }, 237 "cssContentHash": CssContentHash, 238 "fileTree": filetree.FileTree, 239 "pathUnescape": func(s string) string { 240 u, _ := url.PathUnescape(s) 241 return u ··· 253 }, 254 "layoutCenter": func() string { 255 return "col-span-1 md:col-span-8 lg:col-span-6" 256 }, 257 } 258 }
··· 1 package pages 2 3 import ( 4 + "context" 5 "crypto/hmac" 6 "crypto/sha256" 7 "encoding/hex" ··· 19 20 "github.com/dustin/go-humanize" 21 "github.com/go-enry/go-enry/v2" 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 + "tangled.sh/tangled.sh/core/crypto" 25 ) 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 29 "split": func(s string) []string { 30 return strings.Split(s, "\n") 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 35 + "resolve": func(s string) string { 36 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 37 + 38 + if err != nil { 39 + return s 40 + } 41 + 42 + if identity.Handle.IsInvalidHandle() { 43 + return "handle.invalid" 44 + } 45 + 46 + return "@" + identity.Handle.String() 47 + }, 48 "truncateAt30": func(s string) string { 49 if len(s) <= 30 { 50 return s ··· 91 "negf64": func(a float64) float64 { 92 return -a 93 }, 94 + "cond": func(cond any, a, b string) string { 95 if cond == nil { 96 return b 97 } ··· 184 return html.UnescapeString(s) 185 }, 186 "nl2br": func(text string) template.HTML { 187 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 188 }, 189 "unwrapText": func(text string) string { 190 paragraphs := strings.Split(text, "\n\n") ··· 210 } 211 return v.Slice(0, min(n, v.Len())).Interface() 212 }, 213 "markdown": func(text string) template.HTML { 214 + p.rctx.RendererType = markup.RendererTypeDefault 215 + htmlString := p.rctx.RenderMarkdown(text) 216 + sanitized := p.rctx.SanitizeDefault(htmlString) 217 + return template.HTML(sanitized) 218 + }, 219 + "description": func(text string) template.HTML { 220 + p.rctx.RendererType = markup.RendererTypeDefault 221 + htmlString := p.rctx.RenderMarkdown(text) 222 + sanitized := p.rctx.SanitizeDescription(htmlString) 223 + return template.HTML(sanitized) 224 }, 225 "isNil": func(t any) bool { 226 // returns false for other "zero" values ··· 260 }, 261 "cssContentHash": CssContentHash, 262 "fileTree": filetree.FileTree, 263 + "pathEscape": func(s string) string { 264 + return url.PathEscape(s) 265 + }, 266 "pathUnescape": func(s string) string { 267 u, _ := url.PathUnescape(s) 268 return u ··· 280 }, 281 "layoutCenter": func() string { 282 return "col-span-1 md:col-span-8 lg:col-span-6" 283 + }, 284 + 285 + "normalizeForHtmlId": func(s string) string { 286 + // TODO: extend this to handle other cases? 287 + return strings.ReplaceAll(s, ":", "_") 288 + }, 289 + "sshFingerprint": func(pubKey string) string { 290 + fp, err := crypto.SSHFingerprint(pubKey) 291 + if err != nil { 292 + return "error" 293 + } 294 + return fp 295 }, 296 } 297 }
+12
appview/pages/markup/format.go
··· 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 func GetFormat(filename string) Format { 17 for format, extensions := range FileTypes { 18 for _, extension := range extensions {
··· 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 + // ReadmeFilenames contains the list of common README filenames to search for, 17 + // in order of preference. Only includes well-supported formats. 18 + var ReadmeFilenames = []string{ 19 + "README.md", "readme.md", 20 + "README", 21 + "readme", 22 + "README.markdown", 23 + "readme.markdown", 24 + "README.txt", 25 + "readme.txt", 26 + } 27 + 28 func GetFormat(filename string) Format { 29 for format, extensions := range FileTypes { 30 for _, extension := range extensions {
+73 -39
appview/pages/markup/markdown.go
··· 9 "path" 10 "strings" 11 12 - "github.com/microcosm-cc/bluemonday" 13 "github.com/yuin/goldmark" 14 "github.com/yuin/goldmark/ast" 15 "github.com/yuin/goldmark/extension" 16 "github.com/yuin/goldmark/parser" ··· 19 "github.com/yuin/goldmark/util" 20 htmlparse "golang.org/x/net/html" 21 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 ) 24 ··· 40 repoinfo.RepoInfo 41 IsDev bool 42 RendererType RendererType 43 } 44 45 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 48 goldmark.WithParserOptions( 49 parser.WithAutoHeadingID(), 50 ), ··· 145 } 146 } 147 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") 159 - 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) 177 } 178 179 type MarkdownTransformer struct { ··· 189 switch a.rctx.RendererType { 190 case RendererTypeRepoMarkdown: 191 switch n := n.(type) { 192 case *ast.Link: 193 a.rctx.relativeLinkTransformer(n) 194 case *ast.Image: ··· 197 } 198 case RendererTypeDefault: 199 switch n := n.(type) { 200 case *ast.Image: 201 a.rctx.imageFromKnotAstTransformer(n) 202 a.rctx.camoImageLinkAstTransformer(n) ··· 211 212 dst := string(link.Destination) 213 214 - if isAbsoluteUrl(dst) { 215 return 216 } 217 ··· 233 234 actualPath := rctx.actualPath(dst) 235 236 parsedURL := &url.URL{ 237 - Scheme: scheme, 238 - Host: rctx.Knot, 239 - Path: path.Join("/", 240 - rctx.RepoInfo.OwnerDid, 241 - rctx.RepoInfo.Name, 242 - "raw", 243 - url.PathEscape(rctx.RepoInfo.Ref), 244 - actualPath), 245 } 246 newPath := parsedURL.String() 247 return newPath ··· 252 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 } 254 255 // actualPath decides when to join the file path with the 256 // current repository directory (essentially only when the link 257 // destination is relative. if it's absolute then we assume the ··· 271 } 272 return parsed.IsAbs() 273 }
··· 9 "path" 10 "strings" 11 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 14 + treeblood "github.com/wyatt915/goldmark-treeblood" 15 "github.com/yuin/goldmark" 16 + highlighting "github.com/yuin/goldmark-highlighting/v2" 17 "github.com/yuin/goldmark/ast" 18 "github.com/yuin/goldmark/extension" 19 "github.com/yuin/goldmark/parser" ··· 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 ) 28 ··· 44 repoinfo.RepoInfo 45 IsDev bool 46 RendererType RendererType 47 + Sanitizer Sanitizer 48 } 49 50 func (rctx *RenderContext) RenderMarkdown(source string) string { 51 md := goldmark.New( 52 + goldmark.WithExtensions( 53 + extension.GFM, 54 + highlighting.NewHighlighting( 55 + highlighting.WithFormatOptions( 56 + chromahtml.Standalone(false), 57 + chromahtml.WithClasses(true), 58 + ), 59 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 60 + ), 61 + extension.NewFootnote( 62 + extension.WithFootnoteIDPrefix([]byte("footnote")), 63 + ), 64 + treeblood.MathML(), 65 + ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), 68 ), ··· 163 } 164 } 165 166 + func (rctx *RenderContext) SanitizeDefault(html string) string { 167 + return rctx.Sanitizer.SanitizeDefault(html) 168 + } 169 170 + func (rctx *RenderContext) SanitizeDescription(html string) string { 171 + return rctx.Sanitizer.SanitizeDescription(html) 172 } 173 174 type MarkdownTransformer struct { ··· 184 switch a.rctx.RendererType { 185 case RendererTypeRepoMarkdown: 186 switch n := n.(type) { 187 + case *ast.Heading: 188 + a.rctx.anchorHeadingTransformer(n) 189 case *ast.Link: 190 a.rctx.relativeLinkTransformer(n) 191 case *ast.Image: ··· 194 } 195 case RendererTypeDefault: 196 switch n := n.(type) { 197 + case *ast.Heading: 198 + a.rctx.anchorHeadingTransformer(n) 199 case *ast.Image: 200 a.rctx.imageFromKnotAstTransformer(n) 201 a.rctx.camoImageLinkAstTransformer(n) ··· 210 211 dst := string(link.Destination) 212 213 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 214 return 215 } 216 ··· 232 233 actualPath := rctx.actualPath(dst) 234 235 + repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 236 + 237 + query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 238 + url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 239 + 240 parsedURL := &url.URL{ 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 245 } 246 newPath := parsedURL.String() 247 return newPath ··· 252 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 } 254 255 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 256 + idGeneric, exists := h.AttributeString("id") 257 + if !exists { 258 + return // no id, nothing to do 259 + } 260 + id, ok := idGeneric.([]byte) 261 + if !ok { 262 + return 263 + } 264 + 265 + // create anchor link 266 + anchor := ast.NewLink() 267 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 268 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 269 + 270 + // create icon text 271 + iconText := ast.NewString([]byte("#")) 272 + anchor.AppendChild(anchor, iconText) 273 + 274 + // set class on heading 275 + h.SetAttribute([]byte("class"), []byte("heading")) 276 + 277 + // append anchor to heading 278 + h.AppendChild(h, anchor) 279 + } 280 + 281 // actualPath decides when to join the file path with the 282 // current repository directory (essentially only when the link 283 // destination is relative. if it's absolute then we assume the ··· 297 } 298 return parsed.IsAbs() 299 } 300 + 301 + func isFragment(link string) bool { 302 + return strings.HasPrefix(link, "#") 303 + } 304 + 305 + func isMail(link string) bool { 306 + return strings.HasPrefix(link, "mailto:") 307 + }
+134
appview/pages/markup/sanitizer.go
···
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + // math 101 + mathAttrs := []string{ 102 + "accent", "columnalign", "columnlines", "columnspan", "dir", "display", 103 + "displaystyle", "encoding", "fence", "form", "largeop", "linebreak", 104 + "linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize", 105 + "movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan", 106 + "scriptlevel", "stretchy", "symmetric", "title", "voffset", "width", 107 + } 108 + mathElements := []string{ 109 + "annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts", 110 + "mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace", 111 + "msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext", 112 + "mtr", "munder", "munderover", "semantics", 113 + } 114 + policy.AllowNoAttrs().OnElements(mathElements...) 115 + policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 + 117 + return policy 118 + } 119 + 120 + func descriptionPolicy() *bluemonday.Policy { 121 + policy := bluemonday.NewPolicy() 122 + policy.AllowStandardURLs() 123 + 124 + // allow italics and bold. 125 + policy.AllowElements("i", "b", "em", "strong") 126 + 127 + // allow code. 128 + policy.AllowElements("code") 129 + 130 + // allow links 131 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 132 + 133 + return policy 134 + }
+477 -251
appview/pages/pages.go
··· 9 "html/template" 10 "io" 11 "io/fs" 12 - "log" 13 "net/http" 14 "os" 15 "path/filepath" ··· 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 "tangled.sh/tangled.sh/core/patchutil" 28 "tangled.sh/tangled.sh/core/types" 29 ··· 31 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 32 "github.com/alecthomas/chroma/v2/lexers" 33 "github.com/alecthomas/chroma/v2/styles" 34 "github.com/bluesky-social/indigo/atproto/syntax" 35 "github.com/go-git/go-git/v5/plumbing" 36 "github.com/go-git/go-git/v5/plumbing/object" ··· 40 var Files embed.FS 41 42 type Pages struct { 43 - mu sync.RWMutex 44 - t map[string]*template.Template 45 46 avatar config.AvatarConfig 47 dev bool 48 - embedFS embed.FS 49 templateDir string // Path to templates on disk for dev mode 50 rctx *markup.RenderContext 51 } 52 53 - func NewPages(config *config.Config) *Pages { 54 // initialized with safe defaults, can be overriden per use 55 rctx := &markup.RenderContext{ 56 IsDev: config.Core.Dev, 57 CamoUrl: config.Camo.Host, 58 CamoSecret: config.Camo.SharedSecret, 59 } 60 61 p := &Pages{ 62 mu: sync.RWMutex{}, 63 - t: make(map[string]*template.Template), 64 dev: config.Core.Dev, 65 avatar: config.Avatar, 66 - embedFS: Files, 67 rctx: rctx, 68 templateDir: "appview/pages", 69 } 70 71 - // Initial load of all templates 72 - p.loadAllTemplates() 73 74 return p 75 } 76 77 - func (p *Pages) loadAllTemplates() { 78 - templates := make(map[string]*template.Template) 79 var fragmentPaths []string 80 - 81 - // Use embedded FS for initial loading 82 - // First, collect all fragment paths 83 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 84 if err != nil { 85 return err ··· 93 if !strings.Contains(path, "fragments/") { 94 return nil 95 } 96 - name := strings.TrimPrefix(path, "templates/") 97 - name = strings.TrimSuffix(name, ".html") 98 - tmpl, err := template.New(name). 99 - Funcs(p.funcMap()). 100 - ParseFS(p.embedFS, path) 101 - if err != nil { 102 - log.Fatalf("setting up fragment: %v", err) 103 - } 104 - templates[name] = tmpl 105 fragmentPaths = append(fragmentPaths, path) 106 - log.Printf("loaded fragment: %s", name) 107 return nil 108 }) 109 if err != nil { 110 - log.Fatalf("walking template dir for fragments: %v", err) 111 } 112 113 - // Then walk through and setup the rest of the templates 114 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 115 - if err != nil { 116 - return err 117 - } 118 - if d.IsDir() { 119 - return nil 120 - } 121 - if !strings.HasSuffix(path, "html") { 122 - return nil 123 - } 124 - // Skip fragments as they've already been loaded 125 - if strings.Contains(path, "fragments/") { 126 - return nil 127 - } 128 - // Skip layouts 129 - if strings.Contains(path, "layouts/") { 130 - return nil 131 - } 132 - name := strings.TrimPrefix(path, "templates/") 133 - name = strings.TrimSuffix(name, ".html") 134 - // Add the page template on top of the base 135 - allPaths := []string{} 136 - allPaths = append(allPaths, "templates/layouts/*.html") 137 - allPaths = append(allPaths, fragmentPaths...) 138 - allPaths = append(allPaths, path) 139 - tmpl, err := template.New(name). 140 - Funcs(p.funcMap()). 141 - ParseFS(p.embedFS, allPaths...) 142 - if err != nil { 143 - return fmt.Errorf("setting up template: %w", err) 144 - } 145 - templates[name] = tmpl 146 - log.Printf("loaded template: %s", name) 147 - return nil 148 - }) 149 if err != nil { 150 - log.Fatalf("walking template dir: %v", err) 151 } 152 153 - log.Printf("total templates loaded: %d", len(templates)) 154 - p.mu.Lock() 155 - defer p.mu.Unlock() 156 - p.t = templates 157 } 158 159 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 160 - func (p *Pages) loadTemplateFromDisk(name string) error { 161 - if !p.dev { 162 - return nil 163 } 164 165 - log.Printf("reloading template from disk: %s", name) 166 - 167 - // Find all fragments first 168 - var fragmentPaths []string 169 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 170 - if err != nil { 171 - return err 172 - } 173 - if d.IsDir() { 174 - return nil 175 - } 176 - if !strings.HasSuffix(path, ".html") { 177 - return nil 178 - } 179 - if !strings.Contains(path, "fragments/") { 180 - return nil 181 - } 182 - fragmentPaths = append(fragmentPaths, path) 183 - return nil 184 - }) 185 if err != nil { 186 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 187 } 188 189 - // Find the template path on disk 190 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 191 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 192 - return fmt.Errorf("template not found on disk: %s", name) 193 } 194 - 195 - // Create a new template 196 - tmpl := template.New(name).Funcs(p.funcMap()) 197 198 - // Parse layouts 199 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 200 - layouts, err := filepath.Glob(layoutGlob) 201 - if err != nil { 202 - return fmt.Errorf("finding layout templates: %w", err) 203 } 204 205 - // Create paths for parsing 206 - allFiles := append(layouts, fragmentPaths...) 207 - allFiles = append(allFiles, templatePath) 208 209 - // Parse all templates 210 - tmpl, err = tmpl.ParseFiles(allFiles...) 211 if err != nil { 212 - return fmt.Errorf("parsing template files: %w", err) 213 } 214 215 - // Update the template in the map 216 - p.mu.Lock() 217 - defer p.mu.Unlock() 218 - p.t[name] = tmpl 219 - log.Printf("template reloaded from disk: %s", name) 220 - return nil 221 } 222 223 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 224 - // In dev mode, reload the template from disk before executing 225 - if p.dev { 226 - if err := p.loadTemplateFromDisk(templateName); err != nil { 227 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 228 - // Continue with the existing template 229 - } 230 } 231 232 - p.mu.RLock() 233 - defer p.mu.RUnlock() 234 - tmpl, exists := p.t[templateName] 235 - if !exists { 236 - return fmt.Errorf("template not found: %s", templateName) 237 } 238 239 - if base == "" { 240 - return tmpl.Execute(w, params) 241 - } else { 242 - return tmpl.ExecuteTemplate(w, base, params) 243 - } 244 } 245 246 - func (p *Pages) execute(name string, w io.Writer, params any) error { 247 - return p.executeOrReload(name, w, "layouts/base", params) 248 - } 249 250 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 251 - return p.executeOrReload(name, w, "", params) 252 } 253 254 - func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 255 - return p.executeOrReload(name, w, "layouts/repobase", params) 256 } 257 258 type LoginParams struct { 259 } 260 261 func (p *Pages) Login(w io.Writer, params LoginParams) error { 262 return p.executePlain("user/login", w, params) 263 } 264 265 type TimelineParams struct { 266 LoggedInUser *oauth.User 267 Timeline []db.TimelineEvent 268 - DidHandleMap map[string]string 269 } 270 271 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 272 - return p.execute("timeline", w, params) 273 } 274 275 - type SettingsParams struct { 276 LoggedInUser *oauth.User 277 PubKeys []db.PublicKey 278 Emails []db.Email 279 } 280 281 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 282 - return p.execute("settings", w, params) 283 } 284 285 type KnotsParams struct { ··· 293 294 type KnotParams struct { 295 LoggedInUser *oauth.User 296 - DidHandleMap map[string]string 297 Registration *db.Registration 298 Members []string 299 Repos map[string][]db.Repo ··· 305 } 306 307 type KnotListingParams struct { 308 - db.Registration 309 } 310 311 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 312 return p.executePlain("knots/fragments/knotListing", w, params) 313 } 314 315 - type KnotListingFullParams struct { 316 - Registrations []db.Registration 317 - } 318 - 319 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 320 - return p.executePlain("knots/fragments/knotListingFull", w, params) 321 - } 322 - 323 - type KnotSecretParams struct { 324 - Secret string 325 - } 326 - 327 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 328 - return p.executePlain("knots/fragments/secret", w, params) 329 - } 330 - 331 type SpindlesParams struct { 332 LoggedInUser *oauth.User 333 Spindles []db.Spindle ··· 350 Spindle db.Spindle 351 Members []string 352 Repos map[string][]db.Repo 353 - DidHandleMap map[string]string 354 } 355 356 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 376 return p.execute("repo/fork", w, params) 377 } 378 379 - type ProfilePageParams struct { 380 LoggedInUser *oauth.User 381 Repos []db.Repo 382 CollaboratingRepos []db.Repo 383 ProfileTimeline *db.ProfileTimeline 384 - Card ProfileCard 385 - Punchcard db.Punchcard 386 387 - DidHandleMap map[string]string 388 } 389 390 - type ProfileCard struct { 391 - UserDid string 392 - UserHandle string 393 - FollowStatus db.FollowStatus 394 - AvatarUri string 395 - Followers int 396 - Following int 397 398 - Profile *db.Profile 399 } 400 401 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 402 - return p.execute("user/profile", w, params) 403 } 404 405 - type ReposPageParams struct { 406 LoggedInUser *oauth.User 407 - Repos []db.Repo 408 - Card ProfileCard 409 410 - DidHandleMap map[string]string 411 } 412 413 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 414 - return p.execute("user/repos", w, params) 415 } 416 417 type FollowFragmentParams struct { ··· 436 LoggedInUser *oauth.User 437 Profile *db.Profile 438 AllRepos []PinnedRepo 439 - DidHandleMap map[string]string 440 } 441 442 type PinnedRepo struct { ··· 471 } 472 473 type RepoIndexParams struct { 474 - LoggedInUser *oauth.User 475 - RepoInfo repoinfo.RepoInfo 476 - Active string 477 - TagMap map[string][]string 478 - CommitsTrunc []*object.Commit 479 - TagsTrunc []*types.TagReference 480 - BranchesTrunc []types.Branch 481 - ForkInfo *types.ForkInfo 482 HTMLReadme template.HTML 483 Raw bool 484 EmailToDidOrHandle map[string]string 485 VerifiedCommits commitverify.VerifiedCommits 486 Languages []types.RepoLanguageDetails 487 Pipelines map[string]db.Pipeline 488 types.RepoIndexResponse 489 } 490 ··· 494 return p.executeRepo("repo/empty", w, params) 495 } 496 497 p.rctx.RepoInfo = params.RepoInfo 498 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 499 500 if params.ReadmeFileName != "" { 501 - var htmlString string 502 ext := filepath.Ext(params.ReadmeFileName) 503 switch ext { 504 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 505 - htmlString = p.rctx.Sanitize(htmlString) 506 - htmlString = p.rctx.RenderMarkdown(params.Readme) 507 params.Raw = false 508 - params.HTMLReadme = template.HTML(htmlString) 509 default: 510 params.Raw = true 511 } ··· 581 582 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 583 params.Active = "overview" 584 - return p.execute("repo/tree", w, params) 585 } 586 587 type RepoBranchesParams struct { ··· 632 ShowRendered bool 633 RenderToggle bool 634 RenderedContents template.HTML 635 - types.RepoBlobResponse 636 } 637 638 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 644 p.rctx.RepoInfo = params.RepoInfo 645 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 646 htmlString := p.rctx.RenderMarkdown(params.Contents) 647 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 648 } 649 } 650 651 - if params.Lines < 5000 { 652 - c := params.Contents 653 - formatter := chromahtml.New( 654 - chromahtml.InlineCode(false), 655 - chromahtml.WithLineNumbers(true), 656 - chromahtml.WithLinkableLineNumbers(true, "L"), 657 - chromahtml.Standalone(false), 658 - chromahtml.WithClasses(true), 659 - ) 660 - 661 - lexer := lexers.Get(filepath.Base(params.Path)) 662 - if lexer == nil { 663 - lexer = lexers.Fallback 664 - } 665 666 - iterator, err := lexer.Tokenise(nil, c) 667 - if err != nil { 668 - return fmt.Errorf("chroma tokenize: %w", err) 669 - } 670 671 - var code bytes.Buffer 672 - err = formatter.Format(&code, style, iterator) 673 - if err != nil { 674 - return fmt.Errorf("chroma format: %w", err) 675 - } 676 677 - params.Contents = code.String() 678 } 679 680 params.Active = "overview" 681 return p.executeRepo("repo/blob", w, params) 682 } ··· 755 RepoInfo repoinfo.RepoInfo 756 Active string 757 Issues []db.Issue 758 - DidHandleMap map[string]string 759 Page pagination.Page 760 FilteringByOpen bool 761 } ··· 769 LoggedInUser *oauth.User 770 RepoInfo repoinfo.RepoInfo 771 Active string 772 - Issue db.Issue 773 - Comments []db.Comment 774 IssueOwnerHandle string 775 - DidHandleMap map[string]string 776 777 OrderedReactionKinds []db.ReactionKind 778 Reactions map[db.ReactionKind]int 779 UserReacted map[db.ReactionKind]bool 780 781 - State string 782 } 783 784 type ThreadReactionFragmentParams struct { ··· 792 return p.executePlain("repo/fragments/reaction", w, params) 793 } 794 795 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 796 - params.Active = "issues" 797 - if params.Issue.Open { 798 - params.State = "open" 799 - } else { 800 - params.State = "closed" 801 - } 802 - return p.execute("repo/issues/issue", w, params) 803 - } 804 - 805 type RepoNewIssueParams struct { 806 LoggedInUser *oauth.User 807 RepoInfo repoinfo.RepoInfo 808 Active string 809 } 810 811 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 812 params.Active = "issues" 813 return p.executeRepo("repo/issues/new", w, params) 814 } 815 ··· 817 LoggedInUser *oauth.User 818 RepoInfo repoinfo.RepoInfo 819 Issue *db.Issue 820 - Comment *db.Comment 821 } 822 823 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 824 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 825 } 826 827 - type SingleIssueCommentParams struct { 828 LoggedInUser *oauth.User 829 - DidHandleMap map[string]string 830 RepoInfo repoinfo.RepoInfo 831 Issue *db.Issue 832 - Comment *db.Comment 833 } 834 835 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 836 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 837 } 838 839 type RepoNewPullParams struct { ··· 858 RepoInfo repoinfo.RepoInfo 859 Pulls []*db.Pull 860 Active string 861 - DidHandleMap map[string]string 862 FilteringBy db.PullState 863 Stacks map[string]db.Stack 864 Pipelines map[string]db.Pipeline ··· 891 LoggedInUser *oauth.User 892 RepoInfo repoinfo.RepoInfo 893 Active string 894 - DidHandleMap map[string]string 895 Pull *db.Pull 896 Stack db.Stack 897 AbandonedPulls []*db.Pull ··· 911 912 type RepoPullPatchParams struct { 913 LoggedInUser *oauth.User 914 - DidHandleMap map[string]string 915 RepoInfo repoinfo.RepoInfo 916 Pull *db.Pull 917 Stack db.Stack ··· 929 930 type RepoPullInterdiffParams struct { 931 LoggedInUser *oauth.User 932 - DidHandleMap map[string]string 933 RepoInfo repoinfo.RepoInfo 934 Pull *db.Pull 935 Round int ··· 1120 return p.executeRepo("repo/pipelines/workflow", w, params) 1121 } 1122 1123 func (p *Pages) Static() http.Handler { 1124 if p.dev { 1125 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1127 1128 sub, err := fs.Sub(Files, "static") 1129 if err != nil { 1130 - log.Fatalf("no static dir found? that's crazy: %v", err) 1131 } 1132 // Custom handler to apply Cache-Control headers for font files 1133 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1150 func CssContentHash() string { 1151 cssFile, err := Files.Open("static/tw.css") 1152 if err != nil { 1153 - log.Printf("Error opening CSS file: %v", err) 1154 return "" 1155 } 1156 defer cssFile.Close() 1157 1158 hasher := sha256.New() 1159 if _, err := io.Copy(hasher, cssFile); err != nil { 1160 - log.Printf("Error hashing CSS file: %v", err) 1161 return "" 1162 } 1163 ··· 1170 1171 func (p *Pages) Error404(w io.Writer) error { 1172 return p.execute("errors/404", w, nil) 1173 } 1174 1175 func (p *Pages) Error503(w io.Writer) error {
··· 9 "html/template" 10 "io" 11 "io/fs" 12 + "log/slog" 13 "net/http" 14 "os" 15 "path/filepath" ··· 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 + "tangled.sh/tangled.sh/core/idresolver" 28 "tangled.sh/tangled.sh/core/patchutil" 29 "tangled.sh/tangled.sh/core/types" 30 ··· 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 "github.com/alecthomas/chroma/v2/lexers" 34 "github.com/alecthomas/chroma/v2/styles" 35 + "github.com/bluesky-social/indigo/atproto/identity" 36 "github.com/bluesky-social/indigo/atproto/syntax" 37 "github.com/go-git/go-git/v5/plumbing" 38 "github.com/go-git/go-git/v5/plumbing/object" ··· 42 var Files embed.FS 43 44 type Pages struct { 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 48 avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 50 dev bool 51 + embedFS fs.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 + logger *slog.Logger 55 } 56 57 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 + Sanitizer: markup.NewSanitizer(), 64 } 65 66 p := &Pages{ 67 mu: sync.RWMutex{}, 68 + cache: NewTmplCache[string, *template.Template](), 69 dev: config.Core.Dev, 70 avatar: config.Avatar, 71 rctx: rctx, 72 + resolver: res, 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 75 } 76 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 82 83 return p 84 } 85 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 + 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 96 var fragmentPaths []string 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 if err != nil { 99 return err ··· 107 if !strings.Contains(path, "fragments/") { 108 return nil 109 } 110 fragmentPaths = append(fragmentPaths, path) 111 return nil 112 }) 113 if err != nil { 114 + return nil, err 115 } 116 117 + return fragmentPaths, nil 118 + } 119 + 120 + // parse without memoization 121 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 122 + paths, err := p.fragmentPaths() 123 if err != nil { 124 + return nil, err 125 + } 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 128 + } 129 + 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 135 + if err != nil { 136 + return nil, err 137 } 138 139 + return parsed, nil 140 } 141 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 144 + 145 + // never cache in dev mode 146 + if cached, exists := p.cache.Get(key); !p.dev && exists { 147 + return cached, nil 148 } 149 150 + result, err := p.rawParse(stack...) 151 if err != nil { 152 + return nil, err 153 } 154 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 158 + 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 163 } 164 + return p.parse(stack...) 165 + } 166 167 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 168 + stack := []string{ 169 + "layouts/base", 170 + "layouts/repobase", 171 + top, 172 } 173 + return p.parse(stack...) 174 + } 175 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 181 + } 182 + return p.parse(stack...) 183 + } 184 185 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 186 + tpl, err := p.parse(name) 187 if err != nil { 188 + return err 189 } 190 191 + return tpl.Execute(w, params) 192 } 193 194 + func (p *Pages) execute(name string, w io.Writer, params any) error { 195 + tpl, err := p.parseBase(name) 196 + if err != nil { 197 + return err 198 } 199 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 201 + } 202 + 203 + func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 204 + tpl, err := p.parseRepoBase(name) 205 + if err != nil { 206 + return err 207 } 208 209 + return tpl.ExecuteTemplate(w, "layouts/base", params) 210 } 211 212 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 213 + tpl, err := p.parseProfileBase(name) 214 + if err != nil { 215 + return err 216 + } 217 218 + return tpl.ExecuteTemplate(w, "layouts/base", params) 219 } 220 221 + func (p *Pages) Favicon(w io.Writer) error { 222 + return p.executePlain("favicon", w, nil) 223 } 224 225 type LoginParams struct { 226 + ReturnUrl string 227 } 228 229 func (p *Pages) Login(w io.Writer, params LoginParams) error { 230 return p.executePlain("user/login", w, params) 231 } 232 233 + func (p *Pages) Signup(w io.Writer) error { 234 + return p.executePlain("user/signup", w, nil) 235 + } 236 + 237 + func (p *Pages) CompleteSignup(w io.Writer) error { 238 + return p.executePlain("user/completeSignup", w, nil) 239 + } 240 + 241 + type TermsOfServiceParams struct { 242 + LoggedInUser *oauth.User 243 + Content template.HTML 244 + } 245 + 246 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 + filename := "terms.md" 248 + filePath := filepath.Join("legal", filename) 249 + markdownBytes, err := os.ReadFile(filePath) 250 + if err != nil { 251 + return fmt.Errorf("failed to read %s: %w", filename, err) 252 + } 253 + 254 + p.rctx.RendererType = markup.RendererTypeDefault 255 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 256 + sanitized := p.rctx.SanitizeDefault(htmlString) 257 + params.Content = template.HTML(sanitized) 258 + 259 + return p.execute("legal/terms", w, params) 260 + } 261 + 262 + type PrivacyPolicyParams struct { 263 + LoggedInUser *oauth.User 264 + Content template.HTML 265 + } 266 + 267 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 + filename := "privacy.md" 269 + filePath := filepath.Join("legal", filename) 270 + markdownBytes, err := os.ReadFile(filePath) 271 + if err != nil { 272 + return fmt.Errorf("failed to read %s: %w", filename, err) 273 + } 274 + 275 + p.rctx.RendererType = markup.RendererTypeDefault 276 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 277 + sanitized := p.rctx.SanitizeDefault(htmlString) 278 + params.Content = template.HTML(sanitized) 279 + 280 + return p.execute("legal/privacy", w, params) 281 + } 282 + 283 type TimelineParams struct { 284 LoggedInUser *oauth.User 285 Timeline []db.TimelineEvent 286 + Repos []db.Repo 287 } 288 289 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 290 + return p.execute("timeline/timeline", w, params) 291 + } 292 + 293 + type UserProfileSettingsParams struct { 294 + LoggedInUser *oauth.User 295 + Tabs []map[string]any 296 + Tab string 297 + } 298 + 299 + func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 300 + return p.execute("user/settings/profile", w, params) 301 } 302 303 + type UserKeysSettingsParams struct { 304 LoggedInUser *oauth.User 305 PubKeys []db.PublicKey 306 + Tabs []map[string]any 307 + Tab string 308 + } 309 + 310 + func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 311 + return p.execute("user/settings/keys", w, params) 312 + } 313 + 314 + type UserEmailsSettingsParams struct { 315 + LoggedInUser *oauth.User 316 Emails []db.Email 317 + Tabs []map[string]any 318 + Tab string 319 } 320 321 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 322 + return p.execute("user/settings/emails", w, params) 323 + } 324 + 325 + type UpgradeBannerParams struct { 326 + Registrations []db.Registration 327 + Spindles []db.Spindle 328 + } 329 + 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 332 } 333 334 type KnotsParams struct { ··· 342 343 type KnotParams struct { 344 LoggedInUser *oauth.User 345 Registration *db.Registration 346 Members []string 347 Repos map[string][]db.Repo ··· 353 } 354 355 type KnotListingParams struct { 356 + *db.Registration 357 } 358 359 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 360 return p.executePlain("knots/fragments/knotListing", w, params) 361 } 362 363 type SpindlesParams struct { 364 LoggedInUser *oauth.User 365 Spindles []db.Spindle ··· 382 Spindle db.Spindle 383 Members []string 384 Repos map[string][]db.Repo 385 } 386 387 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 407 return p.execute("repo/fork", w, params) 408 } 409 410 + type ProfileCard struct { 411 + UserDid string 412 + UserHandle string 413 + FollowStatus db.FollowStatus 414 + Punchcard *db.Punchcard 415 + Profile *db.Profile 416 + Stats ProfileStats 417 + Active string 418 + } 419 + 420 + type ProfileStats struct { 421 + RepoCount int64 422 + StarredCount int64 423 + StringCount int64 424 + FollowersCount int64 425 + FollowingCount int64 426 + } 427 + 428 + func (p *ProfileCard) GetTabs() [][]any { 429 + tabs := [][]any{ 430 + {"overview", "overview", "square-chart-gantt", nil}, 431 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 432 + {"starred", "starred", "star", p.Stats.StarredCount}, 433 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 434 + } 435 + 436 + return tabs 437 + } 438 + 439 + type ProfileOverviewParams struct { 440 LoggedInUser *oauth.User 441 Repos []db.Repo 442 CollaboratingRepos []db.Repo 443 ProfileTimeline *db.ProfileTimeline 444 + Card *ProfileCard 445 + Active string 446 + } 447 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 451 + } 452 + 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 458 } 459 460 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 461 + params.Active = "repos" 462 + return p.executeProfile("user/repos", w, params) 463 + } 464 465 + type ProfileStarredParams struct { 466 + LoggedInUser *oauth.User 467 + Repos []db.Repo 468 + Card *ProfileCard 469 + Active string 470 } 471 472 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 473 + params.Active = "starred" 474 + return p.executeProfile("user/starred", w, params) 475 } 476 477 + type ProfileStringsParams struct { 478 LoggedInUser *oauth.User 479 + Strings []db.String 480 + Card *ProfileCard 481 + Active string 482 + } 483 + 484 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 485 + params.Active = "strings" 486 + return p.executeProfile("user/strings", w, params) 487 + } 488 489 + type FollowCard struct { 490 + UserDid string 491 + FollowStatus db.FollowStatus 492 + FollowersCount int64 493 + FollowingCount int64 494 + Profile *db.Profile 495 } 496 497 + type ProfileFollowersParams struct { 498 + LoggedInUser *oauth.User 499 + Followers []FollowCard 500 + Card *ProfileCard 501 + Active string 502 + } 503 + 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 507 + } 508 + 509 + type ProfileFollowingParams struct { 510 + LoggedInUser *oauth.User 511 + Following []FollowCard 512 + Card *ProfileCard 513 + Active string 514 + } 515 + 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 519 } 520 521 type FollowFragmentParams struct { ··· 540 LoggedInUser *oauth.User 541 Profile *db.Profile 542 AllRepos []PinnedRepo 543 } 544 545 type PinnedRepo struct { ··· 574 } 575 576 type RepoIndexParams struct { 577 + LoggedInUser *oauth.User 578 + RepoInfo repoinfo.RepoInfo 579 + Active string 580 + TagMap map[string][]string 581 + CommitsTrunc []*object.Commit 582 + TagsTrunc []*types.TagReference 583 + BranchesTrunc []types.Branch 584 + // ForkInfo *types.ForkInfo 585 HTMLReadme template.HTML 586 Raw bool 587 EmailToDidOrHandle map[string]string 588 VerifiedCommits commitverify.VerifiedCommits 589 Languages []types.RepoLanguageDetails 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 592 types.RepoIndexResponse 593 } 594 ··· 598 return p.executeRepo("repo/empty", w, params) 599 } 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 605 p.rctx.RepoInfo = params.RepoInfo 606 + p.rctx.RepoInfo.Ref = params.Ref 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 608 609 if params.ReadmeFileName != "" { 610 ext := filepath.Ext(params.ReadmeFileName) 611 switch ext { 612 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 613 params.Raw = false 614 + htmlString := p.rctx.RenderMarkdown(params.Readme) 615 + sanitized := p.rctx.SanitizeDefault(htmlString) 616 + params.HTMLReadme = template.HTML(sanitized) 617 default: 618 params.Raw = true 619 } ··· 689 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 params.Active = "overview" 692 + return p.executeRepo("repo/tree", w, params) 693 } 694 695 type RepoBranchesParams struct { ··· 740 ShowRendered bool 741 RenderToggle bool 742 RenderedContents template.HTML 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 749 } 750 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 757 p.rctx.RepoInfo = params.RepoInfo 758 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 759 htmlString := p.rctx.RenderMarkdown(params.Contents) 760 + sanitized := p.rctx.SanitizeDefault(htmlString) 761 + params.RenderedContents = template.HTML(sanitized) 762 } 763 } 764 765 + c := params.Contents 766 + formatter := chromahtml.New( 767 + chromahtml.InlineCode(false), 768 + chromahtml.WithLineNumbers(true), 769 + chromahtml.WithLinkableLineNumbers(true, "L"), 770 + chromahtml.Standalone(false), 771 + chromahtml.WithClasses(true), 772 + ) 773 774 + lexer := lexers.Get(filepath.Base(params.Path)) 775 + if lexer == nil { 776 + lexer = lexers.Fallback 777 + } 778 779 + iterator, err := lexer.Tokenise(nil, c) 780 + if err != nil { 781 + return fmt.Errorf("chroma tokenize: %w", err) 782 + } 783 784 + var code bytes.Buffer 785 + err = formatter.Format(&code, style, iterator) 786 + if err != nil { 787 + return fmt.Errorf("chroma format: %w", err) 788 } 789 790 + params.Contents = code.String() 791 params.Active = "overview" 792 return p.executeRepo("repo/blob", w, params) 793 } ··· 866 RepoInfo repoinfo.RepoInfo 867 Active string 868 Issues []db.Issue 869 Page pagination.Page 870 FilteringByOpen bool 871 } ··· 879 LoggedInUser *oauth.User 880 RepoInfo repoinfo.RepoInfo 881 Active string 882 + Issue *db.Issue 883 + CommentList []db.CommentListItem 884 IssueOwnerHandle string 885 886 OrderedReactionKinds []db.ReactionKind 887 Reactions map[db.ReactionKind]int 888 UserReacted map[db.ReactionKind]bool 889 + } 890 891 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 892 + params.Active = "issues" 893 + return p.executeRepo("repo/issues/issue", w, params) 894 + } 895 + 896 + type EditIssueParams struct { 897 + LoggedInUser *oauth.User 898 + RepoInfo repoinfo.RepoInfo 899 + Issue *db.Issue 900 + Action string 901 + } 902 + 903 + func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 904 + params.Action = "edit" 905 + return p.executePlain("repo/issues/fragments/putIssue", w, params) 906 } 907 908 type ThreadReactionFragmentParams struct { ··· 916 return p.executePlain("repo/fragments/reaction", w, params) 917 } 918 919 type RepoNewIssueParams struct { 920 LoggedInUser *oauth.User 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 923 Active string 924 + Action string 925 } 926 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 928 params.Active = "issues" 929 + params.Action = "create" 930 return p.executeRepo("repo/issues/new", w, params) 931 } 932 ··· 934 LoggedInUser *oauth.User 935 RepoInfo repoinfo.RepoInfo 936 Issue *db.Issue 937 + Comment *db.IssueComment 938 } 939 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 942 } 943 944 + type ReplyIssueCommentPlaceholderParams struct { 945 LoggedInUser *oauth.User 946 RepoInfo repoinfo.RepoInfo 947 Issue *db.Issue 948 + Comment *db.IssueComment 949 + } 950 + 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 952 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 953 } 954 955 + type ReplyIssueCommentParams struct { 956 + LoggedInUser *oauth.User 957 + RepoInfo repoinfo.RepoInfo 958 + Issue *db.Issue 959 + Comment *db.IssueComment 960 + } 961 + 962 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 963 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 964 + } 965 + 966 + type IssueCommentBodyParams struct { 967 + LoggedInUser *oauth.User 968 + RepoInfo repoinfo.RepoInfo 969 + Issue *db.Issue 970 + Comment *db.IssueComment 971 + } 972 + 973 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 974 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 975 } 976 977 type RepoNewPullParams struct { ··· 996 RepoInfo repoinfo.RepoInfo 997 Pulls []*db.Pull 998 Active string 999 FilteringBy db.PullState 1000 Stacks map[string]db.Stack 1001 Pipelines map[string]db.Pipeline ··· 1028 LoggedInUser *oauth.User 1029 RepoInfo repoinfo.RepoInfo 1030 Active string 1031 Pull *db.Pull 1032 Stack db.Stack 1033 AbandonedPulls []*db.Pull ··· 1047 1048 type RepoPullPatchParams struct { 1049 LoggedInUser *oauth.User 1050 RepoInfo repoinfo.RepoInfo 1051 Pull *db.Pull 1052 Stack db.Stack ··· 1064 1065 type RepoPullInterdiffParams struct { 1066 LoggedInUser *oauth.User 1067 RepoInfo repoinfo.RepoInfo 1068 Pull *db.Pull 1069 Round int ··· 1254 return p.executeRepo("repo/pipelines/workflow", w, params) 1255 } 1256 1257 + type PutStringParams struct { 1258 + LoggedInUser *oauth.User 1259 + Action string 1260 + 1261 + // this is supplied in the case of editing an existing string 1262 + String db.String 1263 + } 1264 + 1265 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1266 + return p.execute("strings/put", w, params) 1267 + } 1268 + 1269 + type StringsDashboardParams struct { 1270 + LoggedInUser *oauth.User 1271 + Card ProfileCard 1272 + Strings []db.String 1273 + } 1274 + 1275 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1276 + return p.execute("strings/dashboard", w, params) 1277 + } 1278 + 1279 + type StringTimelineParams struct { 1280 + LoggedInUser *oauth.User 1281 + Strings []db.String 1282 + } 1283 + 1284 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1285 + return p.execute("strings/timeline", w, params) 1286 + } 1287 + 1288 + type SingleStringParams struct { 1289 + LoggedInUser *oauth.User 1290 + ShowRendered bool 1291 + RenderToggle bool 1292 + RenderedContents template.HTML 1293 + String db.String 1294 + Stats db.StringStats 1295 + Owner identity.Identity 1296 + } 1297 + 1298 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1299 + var style *chroma.Style = styles.Get("catpuccin-latte") 1300 + 1301 + if params.ShowRendered { 1302 + switch markup.GetFormat(params.String.Filename) { 1303 + case markup.FormatMarkdown: 1304 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1305 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1306 + sanitized := p.rctx.SanitizeDefault(htmlString) 1307 + params.RenderedContents = template.HTML(sanitized) 1308 + } 1309 + } 1310 + 1311 + c := params.String.Contents 1312 + formatter := chromahtml.New( 1313 + chromahtml.InlineCode(false), 1314 + chromahtml.WithLineNumbers(true), 1315 + chromahtml.WithLinkableLineNumbers(true, "L"), 1316 + chromahtml.Standalone(false), 1317 + chromahtml.WithClasses(true), 1318 + ) 1319 + 1320 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1321 + if lexer == nil { 1322 + lexer = lexers.Fallback 1323 + } 1324 + 1325 + iterator, err := lexer.Tokenise(nil, c) 1326 + if err != nil { 1327 + return fmt.Errorf("chroma tokenize: %w", err) 1328 + } 1329 + 1330 + var code bytes.Buffer 1331 + err = formatter.Format(&code, style, iterator) 1332 + if err != nil { 1333 + return fmt.Errorf("chroma format: %w", err) 1334 + } 1335 + 1336 + params.String.Contents = code.String() 1337 + return p.execute("strings/string", w, params) 1338 + } 1339 + 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1344 func (p *Pages) Static() http.Handler { 1345 if p.dev { 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1348 1349 sub, err := fs.Sub(Files, "static") 1350 if err != nil { 1351 + p.logger.Error("no static dir found? that's crazy", "err", err) 1352 + panic(err) 1353 } 1354 // Custom handler to apply Cache-Control headers for font files 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1372 func CssContentHash() string { 1373 cssFile, err := Files.Open("static/tw.css") 1374 if err != nil { 1375 + slog.Debug("Error opening CSS file", "err", err) 1376 return "" 1377 } 1378 defer cssFile.Close() 1379 1380 hasher := sha256.New() 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1382 + slog.Debug("Error hashing CSS file", "err", err) 1383 return "" 1384 } 1385 ··· 1392 1393 func (p *Pages) Error404(w io.Writer) error { 1394 return p.execute("errors/404", w, nil) 1395 + } 1396 + 1397 + func (p *Pages) ErrorKnot404(w io.Writer) error { 1398 + return p.execute("errors/knot404", w, nil) 1399 } 1400 1401 func (p *Pages) Error503(w io.Writer) error {
+2 -7
appview/pages/repoinfo/repoinfo.go
··· 78 func (r RepoInfo) TabMetadata() map[string]any { 79 meta := make(map[string]any) 80 81 - if r.Stats.PullCount.Open > 0 { 82 - meta["pulls"] = r.Stats.PullCount.Open 83 - } 84 - 85 - if r.Stats.IssueCount.Open > 0 { 86 - meta["issues"] = r.Stats.IssueCount.Open 87 - } 88 89 // more stuff? 90
··· 78 func (r RepoInfo) TabMetadata() map[string]any { 79 meta := make(map[string]any) 80 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 83 84 // more stuff? 85
+38
appview/pages/templates/banner.html
···
··· 1 + {{ define "banner" }} 2 + <div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200"> 3 + <details class="group p-2"> 4 + <summary class="list-none cursor-pointer"> 5 + <div class="flex gap-4 items-center"> 6 + <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span> 8 + 9 + <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span> 10 + <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span> 11 + </div> 12 + </summary> 13 + 14 + {{ if .Registrations }} 15 + <ul class="list-disc mx-12 my-2"> 16 + {{range .Registrations}} 17 + <li>Knot: {{ .Domain }}</li> 18 + {{ end }} 19 + </ul> 20 + {{ end }} 21 + 22 + {{ if .Spindles }} 23 + <ul class="list-disc mx-12 my-2"> 24 + {{range .Spindles}} 25 + <li>Spindle: {{ .Instance }}</li> 26 + {{ end }} 27 + </ul> 28 + {{ end }} 29 + 30 + <div class="mx-6"> 31 + These services may not be fully accessible until upgraded. 32 + <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md"> 34 + Click to read the upgrade guide</a>. 35 + </div> 36 + </details> 37 + </div> 38 + {{ end }}
+24 -4
appview/pages/templates/errors/404.html
··· 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 - <h1>404 &mdash; nothing like that here!</h1> 5 - <p> 6 - It seems we couldn't find what you were looking for. Sorry about that! 7 - </p> 8 {{ end }}
··· 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 + {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; page not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + go back 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 {{ end }}
+35 -2
appview/pages/templates/errors/500.html
··· 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</p> 6 {{ end }}
··· 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 + {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 500 &mdash; internal server error 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + Something went wrong on our end. We've been notified and are working to fix the issue. 18 + </p> 19 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 + <div class="flex items-center gap-2"> 21 + {{ i "info" "w-4 h-4" }} 22 + <span class="font-medium">we're on it!</span> 23 + </div> 24 + <p class="mt-1">Our team has been automatically notified about this error.</p> 25 + </div> 26 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create gap-2"> 28 + {{ i "refresh-cw" "w-4 h-4" }} 29 + try again 30 + </button> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 + {{ i "home" "w-4 h-4" }} 33 + back to home 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 39 {{ end }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 - <h1>503 &mdash; unable to reach knot</h1> 5 - <p> 6 - We were unable to reach the knot hosting this repository. Try again 7 - later. 8 - </p> 9 {{ end }}
··· 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 + {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 503 &mdash; service unavailable 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 + {{ i "refresh-cw" "w-4 h-4" }} 22 + try again 23 + </button> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 + back to timeline 27 + </a> 28 + </div> 29 + </div> 30 + </div> 31 + </div> 32 {{ end }}
+28
appview/pages/templates/errors/knot404.html
···
··· 1 + {{ define "title" }}404 &middot; tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 + {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; repository not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + back to timeline 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 + {{ end }}
+26
appview/pages/templates/favicon.html
···
··· 1 + {{ define "favicon" }} 2 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 + <style> 4 + .favicon-text { 5 + fill: #000000; 6 + stroke: none; 7 + } 8 + 9 + @media (prefers-color-scheme: dark) { 10 + .favicon-text { 11 + fill: #ffffff; 12 + stroke: none; 13 + } 14 + } 15 + </style> 16 + 17 + <g style="display:inline"> 18 + <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 + <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 + aria-label="tangled.sh" 21 + class="favicon-text" 22 + style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 + transform="translate(11.01 6.9)"/> 24 + </g> 25 + </svg> 26 + {{ end }}
+8
appview/pages/templates/fragments/logotype.html
···
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + <span class="font-bold italic">tangled</span> 4 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 + alpha 6 + </span> 7 + <span> 8 + {{ end }}
+90
appview/pages/templates/fragments/multiline-select.html
···
··· 1 + {{ define "fragments/multiline-select" }} 2 + <script> 3 + function highlight(scroll = false) { 4 + document.querySelectorAll(".hl").forEach(el => { 5 + el.classList.remove("hl"); 6 + }); 7 + 8 + const hash = window.location.hash; 9 + if (!hash || !hash.startsWith("#L")) { 10 + return; 11 + } 12 + 13 + const rangeStr = hash.substring(2); 14 + const parts = rangeStr.split("-"); 15 + let startLine, endLine; 16 + 17 + if (parts.length === 2) { 18 + startLine = parseInt(parts[0], 10); 19 + endLine = parseInt(parts[1], 10); 20 + } else { 21 + startLine = parseInt(parts[0], 10); 22 + endLine = startLine; 23 + } 24 + 25 + if (isNaN(startLine) || isNaN(endLine)) { 26 + console.log("nan"); 27 + console.log(startLine); 28 + console.log(endLine); 29 + return; 30 + } 31 + 32 + let target = null; 33 + 34 + for (let i = startLine; i<= endLine; i++) { 35 + const idEl = document.getElementById(`L${i}`); 36 + if (idEl) { 37 + const el = idEl.closest(".line"); 38 + if (el) { 39 + el.classList.add("hl"); 40 + target = el; 41 + } 42 + } 43 + } 44 + 45 + if (scroll && target) { 46 + target.scrollIntoView({ 47 + behavior: "smooth", 48 + block: "center", 49 + }); 50 + } 51 + } 52 + 53 + document.addEventListener("DOMContentLoaded", () => { 54 + console.log("DOMContentLoaded"); 55 + highlight(true); 56 + }); 57 + window.addEventListener("hashchange", () => { 58 + console.log("hashchange"); 59 + highlight(); 60 + }); 61 + window.addEventListener("popstate", () => { 62 + console.log("popstate"); 63 + highlight(); 64 + }); 65 + 66 + const lineNumbers = document.querySelectorAll('a[href^="#L"'); 67 + let startLine = null; 68 + 69 + lineNumbers.forEach(el => { 70 + el.addEventListener("click", (event) => { 71 + event.preventDefault(); 72 + const currentLine = parseInt(el.href.split("#L")[1]); 73 + 74 + if (event.shiftKey && startLine !== null) { 75 + const endLine = currentLine; 76 + const min = Math.min(startLine, endLine); 77 + const max = Math.max(startLine, endLine); 78 + const newHash = `#L${min}-${max}`; 79 + history.pushState(null, '', newHash); 80 + } else { 81 + const newHash = `#L${currentLine}`; 82 + history.pushState(null, '', newHash); 83 + startLine = currentLine; 84 + } 85 + 86 + highlight(); 87 + }); 88 + }); 89 + </script> 90 + {{ end }}
+96 -32
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <div id="left-side" class="flex gap-2 items-center"> 7 - <h1 class="text-xl font-bold dark:text-white"> 8 - {{ .Registration.Domain }} 9 - </h1> 10 - <span class="text-gray-500 text-base"> 11 - {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 - </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 18 {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 {{ end }} 22 - </div> 23 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 </div> 26 27 - {{ if .Members }} 28 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 - <div class="flex flex-col gap-2"> 30 - {{ block "knotMember" . }} {{ end }} 31 - </div> 32 - </section> 33 - {{ end }} 34 {{ end }} 35 36 - {{ define "knotMember" }} 37 {{ range .Members }} 38 <div> 39 <div class="flex justify-between items-center"> 40 <div class="flex items-center gap-2"> 41 - {{ i "user" "size-4" }} 42 - {{ $user := index $.DidHandleMap . }} 43 - <a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a> 44 </div> 45 </div> 46 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 47 {{ $repos := index $.Repos . }} 48 {{ range $repos }} 49 <div class="flex gap-2 items-center"> 50 {{ i "book-marked" "size-4" }} 51 - <a href="/{{ .Did }}/{{ .Name }}"> 52 {{ .Name }} 53 </a> 54 </div> 55 {{ else }} 56 <div class="text-gray-500 dark:text-gray-400"> 57 - No repositories created yet. 58 </div> 59 {{ end }} 60 </div> 61 </div> 62 {{ end }} 63 {{ end }}
··· 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 13 {{ template "knots/fragments/addMemberModal" .Registration }} 14 {{ end }} 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 18 + </span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 26 + {{ end }} 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 32 </div> 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 36 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 44 {{ end }} 45 46 + 47 + {{ define "member" }} 48 {{ range .Members }} 49 <div> 50 <div class="flex justify-between items-center"> 51 <div class="flex items-center gap-2"> 52 + {{ template "user/fragments/picHandleLink" . }} 53 + <span class="ml-2 font-mono text-gray-500">{{.}}</span> 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 58 </div> 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 60 {{ $repos := index $.Repos . }} 61 {{ range $repos }} 62 <div class="flex gap-2 items-center"> 63 {{ i "book-marked" "size-4" }} 64 + <a href="/{{ resolve .Did }}/{{ .Name }}"> 65 {{ .Name }} 66 </a> 67 </div> 68 {{ else }} 69 <div class="text-gray-500 dark:text-gray-400"> 70 + No repositories configured yet. 71 </div> 72 {{ end }} 73 </div> 74 </div> 75 {{ end }} 76 {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 {{ 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 > ··· 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" ··· 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" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 - {{ end }} 58 -
··· 1 {{ define "knots/fragments/addMemberModal" }} 2 <button 3 class="btn gap-2 group" 4 + title="Add member to this knot" 5 popovertarget="add-member-{{ .Id }}" 6 popovertargetaction="toggle" 7 > ··· 20 21 {{ define "addKnotMemberPopover" }} 22 <form 23 + hx-post="/knots/{{ .Domain }}/add" 24 hx-indicator="#spinner" 25 hx-swap="none" 26 class="flex flex-col gap-2" ··· 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 and run workflows on this knot.</p> 32 <input 33 type="text" 34 id="member-did-{{ .Id }}" 35 + name="member" 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" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 + {{ end }}
+57 -25
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 -
··· 1 {{ define "knots/fragments/knotListing" }} 2 + <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "knotLeftSide" . }} {{ end }} 4 + {{ block "knotRightSide" . }} {{ end }} 5 </div> 6 {{ end }} 7 8 + {{ define "knotLeftSide" }} 9 + {{ if .Registered }} 10 + <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 20 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 21 {{ i "hard-drive" "w-4 h-4" }} 22 + {{ .Domain }} 23 <span class="text-gray-500"> 24 {{ template "repo/fragments/shortTimeAgo" .Created }} 25 </span> 26 </div> 27 + {{ end }} 28 {{ end }} 29 30 + {{ define "knotRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 37 {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsNeedsUpgrade }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} needs upgrade 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 45 {{ else }} 46 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 + {{ i "shield-off" "w-4 h-4" }} unverified 48 + </span> 49 + {{ block "knotRetryButton" . }} {{ end }} 50 + {{ block "knotDeleteButton" . }} {{ end }} 51 {{ end }} 52 </div> 53 {{ end }} 54 55 + {{ define "knotDeleteButton" }} 56 + <button 57 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 + title="Delete knot" 59 + hx-delete="/knots/{{ .Domain }}" 60 + hx-swap="outerHTML" 61 + hx-target="#knot-{{.Id}}" 62 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 72 <button 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 76 hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 78 > 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 </button> 83 {{ end }}
-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 }}
···
+34 -17
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 > ··· 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" }} ··· 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 }}
··· 1 {{ define "title" }}knots{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 9 + </span> 10 </div> 11 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 13 <div class="flex flex-col gap-6"> 14 {{ block "about" . }} {{ end }} 15 + {{ block "list" . }} {{ end }} 16 {{ block "register" . }} {{ end }} 17 </div> 18 </section> 19 {{ end }} 20 21 {{ define "about" }} 22 + <section class="rounded"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 25 + When creating a repository, you can choose a knot to store it on. 26 </p> 27 + 28 + 29 + </section> 30 + {{ end }} 31 + 32 + {{ define "list" }} 33 + <section class="rounded w-full flex flex-col gap-2"> 34 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 35 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 36 + {{ range $registration := .Registrations }} 37 + {{ template "knots/fragments/knotListing" . }} 38 + {{ else }} 39 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 40 + no knots registered yet 41 + </div> 42 + {{ end }} 43 + </div> 44 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 45 </section> 46 {{ end }} 47 48 {{ define "register" }} 49 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 50 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 51 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 52 <form 53 + hx-post="/knots/register" 54 + class="max-w-2xl mb-2 space-y-4" 55 hx-indicator="#register-button" 56 hx-swap="none" 57 > ··· 71 > 72 <span class="inline-flex items-center gap-2"> 73 {{ i "plus" "w-4 h-4" }} 74 + register 75 </span> 76 <span class="pl-2 hidden group-[.htmx-request]:inline"> 77 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 79 </button> 80 </div> 81 82 + <div id="register-error" class="error dark:text-red-400"></div> 83 </form> 84 85 </section> 86 {{ end }}
+27 -24
appview/pages/templates/layouts/base.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 name="htmx-config" content='{"includeIndicatorStyles": false}'> 11 - <script src="/static/htmx.min.js"></script> 12 - <script src="/static/htmx-ext-ws.min.js"></script> 13 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 16 </head> 17 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 {{ block "topbarLayout" . }} 19 - <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 - {{ template "layouts/topbar" . }} 21 </header> 22 {{ end }} 23 24 {{ block "mainLayout" . }} 25 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 {{ block "contentLayout" . }} 27 - <div class="col-span-1 md:col-span-2"> 28 - {{ block "contentLeft" . }} {{ end }} 29 - </div> 30 <main class="col-span-1 md:col-span-8"> 31 {{ block "content" . }}{{ end }} 32 </main> 33 - <div class="col-span-1 md:col-span-2"> 34 - {{ block "contentRight" . }} {{ end }} 35 - </div> 36 {{ end }} 37 38 {{ block "contentAfterLayout" . }} 39 - <div class="col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 <main class="col-span-1 md:col-span-8"> 43 {{ block "contentAfter" . }}{{ end }} 44 </main> 45 - <div class="col-span-1 md:col-span-2"> 46 - {{ block "contentAfterRight" . }} {{ end }} 47 - </div> 48 {{ end }} 49 </div> 50 {{ end }} 51 52 {{ block "footerLayout" . }} 53 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 - {{ template "layouts/footer" . }} 55 </footer> 56 {{ end }} 57 </body>
··· 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 name="description" content="Social coding, but for real this time!"/> 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 9 + 10 + <script defer src="/static/htmx.min.js"></script> 11 + <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + 13 + <!-- preconnect to image cdn --> 14 + <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 + <link rel="preconnect" href="https://camo.tangled.sh" /> 16 + 17 + <!-- preload main font --> 18 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 + 20 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </head> 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 25 {{ block "topbarLayout" . }} 26 + <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 27 + 28 + {{ if .LoggedInUser }} 29 + <div id="upgrade-banner" 30 + hx-get="/upgradeBanner" 31 + hx-trigger="load" 32 + hx-swap="innerHTML"> 33 + </div> 34 + {{ end }} 35 + {{ template "layouts/fragments/topbar" . }} 36 </header> 37 {{ end }} 38 39 {{ block "mainLayout" . }} 40 + <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 41 {{ block "contentLayout" . }} 42 <main class="col-span-1 md:col-span-8"> 43 {{ block "content" . }}{{ end }} 44 </main> 45 {{ end }} 46 47 {{ block "contentAfterLayout" . }} 48 <main class="col-span-1 md:col-span-8"> 49 {{ block "contentAfter" . }}{{ end }} 50 </main> 51 {{ end }} 52 </div> 53 {{ end }} 54 55 {{ block "footerLayout" . }} 56 + <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 57 + {{ template "layouts/fragments/footer" . }} 58 </footer> 59 {{ end }} 60 </body>
-7
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 - </div> 6 - </div> 7 - {{ end }}
···
+48
appview/pages/templates/layouts/fragments/footer.html
···
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 + </div> 46 + </div> 47 + </div> 48 + {{ end }}
+78
appview/pages/templates/layouts/fragments/topbar.html
···
··· 1 + {{ define "layouts/fragments/topbar" }} 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 + <div class="flex justify-between p-0 items-center"> 4 + <div id="left-items"> 5 + <a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a> 6 + </div> 7 + 8 + <div id="right-items" class="flex items-center gap-2"> 9 + {{ with .LoggedInUser }} 10 + {{ block "newButton" . }} {{ end }} 11 + {{ block "dropDown" . }} {{ end }} 12 + {{ else }} 13 + <a href="/login">login</a> 14 + <span class="text-gray-500 dark:text-gray-400">or</span> 15 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 16 + join now {{ i "arrow-right" "size-4" }} 17 + </a> 18 + {{ end }} 19 + </div> 20 + </div> 21 + </nav> 22 + {{ end }} 23 + 24 + {{ define "newButton" }} 25 + <details class="relative inline-block text-left nav-dropdown"> 26 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 27 + {{ i "plus" "w-4 h-4" }} new 28 + </summary> 29 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 30 + <a href="/repo/new" class="flex items-center gap-2"> 31 + {{ i "book-plus" "w-4 h-4" }} 32 + new repository 33 + </a> 34 + <a href="/strings/new" class="flex items-center gap-2"> 35 + {{ i "line-squiggle" "w-4 h-4" }} 36 + new string 37 + </a> 38 + </div> 39 + </details> 40 + {{ end }} 41 + 42 + {{ define "dropDown" }} 43 + <details class="relative inline-block text-left nav-dropdown"> 44 + <summary 45 + class="cursor-pointer list-none flex items-center" 46 + > 47 + {{ $user := didOrHandle .Did .Handle }} 48 + {{ template "user/fragments/picHandle" $user }} 49 + </summary> 50 + <div 51 + class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 52 + > 53 + <a href="/{{ $user }}">profile</a> 54 + <a href="/{{ $user }}?tab=repos">repositories</a> 55 + <a href="/{{ $user }}?tab=strings">strings</a> 56 + <a href="/knots">knots</a> 57 + <a href="/spindles">spindles</a> 58 + <a href="/settings">settings</a> 59 + <a href="#" 60 + hx-post="/logout" 61 + hx-swap="none" 62 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 63 + logout 64 + </a> 65 + </div> 66 + </details> 67 + 68 + <script> 69 + document.addEventListener('click', function(event) { 70 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 71 + dropdowns.forEach(function(dropdown) { 72 + if (!dropdown.contains(event.target)) { 73 + dropdown.removeAttribute('open'); 74 + } 75 + }); 76 + }); 77 + </script> 78 + {{ end }}
+109
appview/pages/templates/layouts/profilebase.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + {{ $style := "hidden md:block md:col-span-3" }} 15 + {{ if eq $.Active "overview" }} 16 + {{ $style = "md:col-span-3" }} 17 + {{ end }} 18 + <div class="{{ $style }} order-1 order-1"> 19 + <div class="flex flex-col gap-4"> 20 + {{ template "user/fragments/profileCard" .Card }} 21 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 22 + </div> 23 + </div> 24 + 25 + {{ block "profileContent" . }} {{ end }} 26 + </div> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "profileTabs" }} 31 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 32 + <div class="flex z-60"> 33 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 34 + {{ $tabs := .Card.GetTabs }} 35 + {{ $tabmeta := dict "x" "y" }} 36 + {{ range $item := $tabs }} 37 + {{ $key := index $item 0 }} 38 + {{ $value := index $item 1 }} 39 + {{ $icon := index $item 2 }} 40 + {{ $meta := index $item 3 }} 41 + <a 42 + href="?tab={{ $value }}" 43 + class="relative -mr-px group no-underline hover:no-underline" 44 + hx-boost="true"> 45 + <div 46 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 47 + {{ if eq $.Active $key }} 48 + {{ $activeTabStyles }} 49 + {{ else }} 50 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 + {{ end }} 52 + "> 53 + <span class="flex items-center justify-center"> 54 + {{ i $icon "w-4 h-4 mr-2" }} 55 + {{ $key }} 56 + {{ if $meta }} 57 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + </a> 62 + {{ end }} 63 + </div> 64 + </nav> 65 + {{ end }} 66 + 67 + {{ define "punchcard" }} 68 + {{ $now := now }} 69 + <div> 70 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 71 + PUNCHCARD 72 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 73 + {{ .Total | int64 | commaFmt }} commits 74 + </span> 75 + </p> 76 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 77 + {{ range .Punches }} 78 + {{ $count := .Count }} 79 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 80 + {{ if lt $count 1 }} 81 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 82 + {{ else if lt $count 2 }} 83 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 84 + {{ else if lt $count 4 }} 85 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 86 + {{ else if lt $count 8 }} 87 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 88 + {{ else }} 89 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 90 + {{ end }} 91 + 92 + {{ if .Date.After $now }} 93 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 94 + {{ end }} 95 + <div class="w-full h-full flex justify-center items-center"> 96 + <div 97 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 98 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 99 + </div> 100 + </div> 101 + {{ end }} 102 + </div> 103 + </div> 104 + {{ end }} 105 + 106 + {{ define "layouts/profilebase" }} 107 + {{ template "layouts/base" . }} 108 + {{ end }} 109 +
+20 -29
appview/pages/templates/layouts/repobase.html
··· 5 {{ if .RepoInfo.Source }} 6 <p class="text-sm"> 7 <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1"}} 9 forked from 10 {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> ··· 20 </div> 21 22 <div class="flex items-center gap-2 z-auto"> 23 {{ template "repo/fragments/repoStar" .RepoInfo }} 24 - {{ if .RepoInfo.DisableFork }} 25 - <button 26 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 - disabled 28 - title="Empty repositories cannot be forked" 29 - > 30 - {{ i "git-fork" "w-4 h-4" }} 31 - fork 32 - </button> 33 - {{ else }} 34 - <a 35 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 - hx-boost="true" 37 - href="/{{ .RepoInfo.FullName }}/fork" 38 - > 39 - {{ i "git-fork" "w-4 h-4" }} 40 - fork 41 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 - </a> 43 - {{ end }} 44 </div> 45 </div> 46 {{ template "repo/fragments/repoDescription" . }} 47 </section> 48 49 <section 50 - class="w-full flex flex-col drop-shadow-sm" 51 > 52 <nav class="w-full pl-4 overflow-auto"> 53 <div class="flex z-60"> ··· 76 <span class="flex items-center justify-center"> 77 {{ i $icon "w-4 h-4 mr-2" }} 78 {{ $key }} 79 - {{ if not (isNil $meta) }} 80 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 81 {{ end }} 82 </span> 83 </div> ··· 86 </div> 87 </nav> 88 <section 89 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 90 > 91 {{ block "repoContent" . }}{{ end }} 92 </section> 93 {{ block "repoAfter" . }}{{ end }} 94 </section> 95 {{ end }} 96 - 97 - {{ define "layouts/repobase" }} 98 - {{ template "layouts/base" . }} 99 - {{ end }}
··· 5 {{ if .RepoInfo.Source }} 6 <p class="text-sm"> 7 <div class="flex items-center"> 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 forked from 10 {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> ··· 20 </div> 21 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> 40 </div> 41 {{ template "repo/fragments/repoDescription" . }} 42 </section> 43 44 <section 45 + class="w-full flex flex-col" 46 > 47 <nav class="w-full pl-4 overflow-auto"> 48 <div class="flex z-60"> ··· 71 <span class="flex items-center justify-center"> 72 {{ i $icon "w-4 h-4 mr-2" }} 73 {{ $key }} 74 + {{ if $meta }} 75 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 76 {{ end }} 77 </span> 78 </div> ··· 81 </div> 82 </nav> 83 <section 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 > 86 {{ block "repoContent" . }}{{ end }} 87 </section> 88 {{ block "repoAfter" . }}{{ end }} 89 </section> 90 {{ end }}
-60
appview/pages/templates/layouts/topbar.html
··· 1 - {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="flex justify-between p-0 items-center"> 4 - <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 - 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 23 - {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 27 - {{ block "dropDown" . }} {{ end }} 28 - {{ else }} 29 - <a href="/login">login</a> 30 - {{ end }} 31 - </div> 32 - </div> 33 - </nav> 34 - {{ end }} 35 - 36 - {{ define "dropDown" }} 37 - <details class="relative inline-block text-left"> 38 - <summary 39 - class="cursor-pointer list-none flex items-center" 40 - > 41 - {{ $user := didOrHandle .Did .Handle }} 42 - {{ template "user/fragments/picHandle" $user }} 43 - </summary> 44 - <div 45 - 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" 46 - > 47 - <a href="/{{ $user }}">profile</a> 48 - <a href="/{{ $user }}?tab=repos">repositories</a> 49 - <a href="/knots">knots</a> 50 - <a href="/spindles">spindles</a> 51 - <a href="/settings">settings</a> 52 - <a href="#" 53 - hx-post="/logout" 54 - hx-swap="none" 55 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 56 - logout 57 - </a> 58 - </div> 59 - </details> 60 - {{ end }}
···
+11
appview/pages/templates/legal/privacy.html
···
··· 1 + {{ define "title" }}privacy policy{{ 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 + {{ .Content }} 8 + </div> 9 + </div> 10 + </div> 11 + {{ end }}
+11
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 + {{ .Content }} 8 + </div> 9 + </div> 10 + </div> 11 + {{ end }}
+1
appview/pages/templates/repo/blob.html
··· 78 {{ end }} 79 </div> 80 {{ end }} 81 {{ end }}
··· 78 {{ end }} 79 </div> 80 {{ end }} 81 + {{ template "fragments/multiline-select" }} 82 {{ end }}
+3 -3
appview/pages/templates/repo/commit.html
··· 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 ··· 106 107 {{ define "footerLayout" }} 108 <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 110 </footer> 111 {{ end }} 112 ··· 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 </div> 121 - <div class="sticky top-0 flex-grow max-h-screen"> 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 </div> 124 {{end}}
··· 81 82 {{ define "topbarLayout" }} 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 ··· 106 107 {{ define "footerLayout" }} 108 <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }} 112 ··· 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> 124 {{end}}
+3 -3
appview/pages/templates/repo/compare/compare.html
··· 12 13 {{ define "topbarLayout" }} 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 16 </header> 17 {{ end }} 18 ··· 37 38 {{ define "footerLayout" }} 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 41 </footer> 42 {{ end }} 43 ··· 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 </div> 52 - <div class="sticky top-0 flex-grow max-h-screen"> 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 </div> 55 {{end}}
··· 12 13 {{ define "topbarLayout" }} 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/fragments/topbar" . }} 16 </header> 17 {{ end }} 18 ··· 37 38 {{ define "footerLayout" }} 39 <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/fragments/footer" . }} 41 </footer> 42 {{ end }} 43 ··· 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}}
+5 -7
appview/pages/templates/repo/empty.html
··· 32 <div class="py-6 w-fit flex flex-col gap-4"> 33 <p>This is an empty repository. To get started:</p> 34 {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 - <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 - <p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p> 37 - <p><span class="{{$bullet}}">3</span>Push!</p> 38 </div> 39 </div> 40 {{ else }} ··· 42 {{ end }} 43 </main> 44 {{ end }} 45 - 46 - {{ define "repoAfter" }} 47 - {{ template "repo/fragments/cloneInstructions" . }} 48 - {{ end }}
··· 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> 42 {{ else }} ··· 44 {{ end }} 45 </main> 46 {{ end }}
+9 -3
appview/pages/templates/repo/fork.html
··· 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2"> ··· 19 class="mr-2" 20 id="domain-{{ . }}" 21 /> 22 - <span class="dark:text-white">{{ . }}</span> 23 </div> 24 {{ else }} 25 <p class="dark:text-white">No knots available.</p> ··· 30 </fieldset> 31 32 <div class="space-y-2"> 33 - <button type="submit" class="btn">fork repo</button> 34 <div id="repo" class="error"></div> 35 </div> 36 </form>
··· 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2"> ··· 19 class="mr-2" 20 id="domain-{{ . }}" 21 /> 22 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 </div> 24 {{ else }} 25 <p class="dark:text-white">No knots available.</p> ··· 30 </fieldset> 31 32 <div class="space-y-2"> 33 + <button type="submit" class="btn-create flex items-center gap-2"> 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork repo 36 + <span id="spinner" class="group"> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </span> 39 + </button> 40 <div id="repo" class="error"></div> 41 </div> 42 </form>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
···
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
···
+35 -83
appview/pages/templates/repo/fragments/diff.html
··· 11 {{ $last := sub (len $diff) 1 }} 12 13 <div class="flex flex-col gap-4"> 14 {{ range $idx, $hunk := $diff }} 15 {{ with $hunk }} 16 - <section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 17 - <div id="file-{{ .Name.New }}"> 18 - <div id="diff-file"> 19 - <details open> 20 - <summary class="list-none cursor-pointer sticky top-0"> 21 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 22 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 23 - <div class="flex gap-1 items-center"> 24 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 25 - {{ if .IsNew }} 26 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 27 - {{ else if .IsDelete }} 28 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 29 - {{ else if .IsCopy }} 30 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 31 - {{ else if .IsRename }} 32 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 33 - {{ else }} 34 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 35 - {{ end }} 36 - 37 - {{ template "repo/fragments/diffStatPill" .Stats }} 38 - </div> 39 - 40 - <div class="flex gap-2 items-center overflow-x-auto"> 41 - {{ if .IsDelete }} 42 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 43 - {{ .Name.Old }} 44 - </a> 45 - {{ else if (or .IsCopy .IsRename) }} 46 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 47 - {{ .Name.Old }} 48 - </a> 49 - {{ i "arrow-right" "w-4 h-4" }} 50 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 51 - {{ .Name.New }} 52 - </a> 53 - {{ else }} 54 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 55 - {{ .Name.New }} 56 - </a> 57 - {{ end }} 58 - </div> 59 - </div> 60 - 61 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 62 - <div id="right-side-items" class="p-2 flex items-center"> 63 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 64 - {{ if gt $idx 0 }} 65 - {{ $prev := index $diff (sub $idx 1) }} 66 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 67 - {{ end }} 68 - 69 - {{ if lt $idx $last }} 70 - {{ $next := index $diff (add $idx 1) }} 71 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 72 - {{ end }} 73 - </div> 74 75 - </div> 76 - </summary> 77 - 78 - <div class="transition-all duration-700 ease-in-out"> 79 - {{ if .IsDelete }} 80 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 81 - This file has been deleted. 82 - </p> 83 - {{ else if .IsCopy }} 84 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 85 - This file has been copied. 86 - </p> 87 - {{ else if .IsBinary }} 88 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 89 - This is a binary file and will not be displayed. 90 - </p> 91 - {{ else }} 92 - {{ if $isSplit }} 93 - {{- template "repo/fragments/splitDiff" .Split -}} 94 {{ else }} 95 - {{- template "repo/fragments/unifiedDiff" . -}} 96 {{ end }} 97 - {{- end -}} 98 </div> 99 100 - </details> 101 - 102 </div> 103 - </div> 104 - </section> 105 {{ end }} 106 {{ end }} 107 </div> 108 {{ end }}
··· 11 {{ $last := sub (len $diff) 1 }} 12 13 <div class="flex flex-col gap-4"> 14 + {{ if eq (len $diff) 0 }} 15 + <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 16 + <p>No differences found between the selected revisions.</p> 17 + </div> 18 + {{ else }} 19 {{ range $idx, $hunk := $diff }} 20 {{ with $hunk }} 21 + <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 22 + <summary class="list-none cursor-pointer sticky top-0"> 23 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 24 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 25 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 26 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 27 + {{ template "repo/fragments/diffStatPill" .Stats }} 28 29 + <div class="flex gap-2 items-center overflow-x-auto"> 30 + {{ if .IsDelete }} 31 + {{ .Name.Old }} 32 + {{ else if (or .IsCopy .IsRename) }} 33 + {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 34 {{ else }} 35 + {{ .Name.New }} 36 {{ end }} 37 + </div> 38 </div> 39 + </div> 40 + </summary> 41 42 + <div class="transition-all duration-700 ease-in-out"> 43 + {{ if .IsBinary }} 44 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 45 + This is a binary file and will not be displayed. 46 + </p> 47 + {{ else }} 48 + {{ if $isSplit }} 49 + {{- template "repo/fragments/splitDiff" .Split -}} 50 + {{ else }} 51 + {{- template "repo/fragments/unifiedDiff" . -}} 52 + {{ end }} 53 + {{- end -}} 54 </div> 55 + </details> 56 {{ end }} 57 + {{ end }} 58 {{ end }} 59 </div> 60 {{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
···
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+4 -4
appview/pages/templates/repo/fragments/fileTree.html
··· 3 <details open> 4 <summary class="cursor-pointer list-none pt-1"> 5 <span class="tree-directory inline-flex items-center gap-2 "> 6 - {{ i "folder" "size-4 fill-current" }} 7 - <span class="filename text-black dark:text-white">{{ .Name }}</span> 8 </span> 9 </summary> 10 <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> ··· 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 }}
··· 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"> ··· 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 }}
+44 -69
appview/pages/templates/repo/fragments/interdiff.html
··· 10 <div class="flex flex-col gap-4"> 11 {{ range $idx, $hunk := $diff }} 12 {{ with $hunk }} 13 - <section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 - <div id="file-{{ .Name }}"> 15 - <div id="diff-file"> 16 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 17 - <summary class="list-none cursor-pointer sticky top-0"> 18 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 19 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 20 - <div class="flex gap-1 items-center" style="direction: ltr;"> 21 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 22 - {{ if .Status.IsOk }} 23 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 24 - {{ else if .Status.IsUnchanged }} 25 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 26 - {{ else if .Status.IsOnlyInOne }} 27 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 28 - {{ else if .Status.IsOnlyInTwo }} 29 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 30 - {{ else if .Status.IsRebased }} 31 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 32 - {{ else }} 33 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 34 - {{ end }} 35 - </div> 36 - 37 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 38 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 39 - {{ .Name }} 40 - </a> 41 - </div> 42 - </div> 43 - 44 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 45 - <div id="right-side-items" class="p-2 flex items-center"> 46 - <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 47 - {{ if gt $idx 0 }} 48 - {{ $prev := index $diff (sub $idx 1) }} 49 - <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 50 - {{ end }} 51 - 52 - {{ if lt $idx $last }} 53 - {{ $next := index $diff (add $idx 1) }} 54 - <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 55 - {{ end }} 56 - </div> 57 - 58 </div> 59 - </summary> 60 61 - <div class="transition-all duration-700 ease-in-out"> 62 - {{ if .Status.IsUnchanged }} 63 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 64 - This file has not been changed. 65 - </p> 66 - {{ else if .Status.IsRebased }} 67 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 68 - This patch was likely rebased, as context lines do not match. 69 - </p> 70 - {{ else if .Status.IsError }} 71 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 72 - Failed to calculate interdiff for this file. 73 - </p> 74 - {{ else }} 75 - {{ if $isSplit }} 76 - {{- template "repo/fragments/splitDiff" .Split -}} 77 - {{ else }} 78 - {{- template "repo/fragments/unifiedDiff" . -}} 79 - {{ end }} 80 - {{- end -}} 81 </div> 82 83 - </details> 84 85 </div> 86 - </div> 87 - </section> 88 {{ end }} 89 {{ end }} 90 </div>
··· 10 <div class="flex flex-col gap-4"> 11 {{ range $idx, $hunk := $diff }} 12 {{ with $hunk }} 13 + <details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 + <summary class="list-none cursor-pointer sticky top-0"> 15 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 16 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 17 + <div class="flex gap-1 items-center" style="direction: ltr;"> 18 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 19 + {{ if .Status.IsOk }} 20 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 21 + {{ else if .Status.IsUnchanged }} 22 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 23 + {{ else if .Status.IsOnlyInOne }} 24 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 25 + {{ else if .Status.IsOnlyInTwo }} 26 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 27 + {{ else if .Status.IsRebased }} 28 + <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 29 + {{ else }} 30 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 31 + {{ end }} 32 </div> 33 34 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div> 35 </div> 36 37 + </div> 38 + </summary> 39 40 + <div class="transition-all duration-700 ease-in-out"> 41 + {{ if .Status.IsUnchanged }} 42 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 43 + This file has not been changed. 44 + </p> 45 + {{ else if .Status.IsRebased }} 46 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 47 + This patch was likely rebased, as context lines do not match. 48 + </p> 49 + {{ else if .Status.IsError }} 50 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 51 + Failed to calculate interdiff for this file. 52 + </p> 53 + {{ else }} 54 + {{ if $isSplit }} 55 + {{- template "repo/fragments/splitDiff" .Split -}} 56 + {{ else }} 57 + {{- template "repo/fragments/unifiedDiff" . -}} 58 + {{ end }} 59 + {{- end -}} 60 </div> 61 + 62 + </details> 63 {{ end }} 64 {{ end }} 65 </div>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 {{ define "repo/fragments/interdiffFiles" }} 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 <div class="diff-stat"> 5 <div class="flex gap-2 items-center"> 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
··· 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>
+6
appview/pages/templates/repo/fragments/languageBall.html
···
··· 1 + {{ define "repo/fragments/languageBall" }} 2 + <div 3 + class="size-2 rounded-full" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));" 5 + ></div> 6 + {{ end }}
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 {{ define "repo/fragments/repoDescription" }} 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
··· 1 {{ define "repo/fragments/repoDescription" }} 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 {{ if .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
···
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+9
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ $formatted := shortRelTimeFmt . }} 3 + {{ $content := printf "%s ago" $formatted }} 4 + {{ if eq $formatted "now" }} 5 + {{ $content = "now" }} 6 + {{ end }} 7 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }} 8 + {{ end }} 9 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 {{ 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 }}
··· 1 {{ define "repo/fragments/time" }} 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 3 {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
···
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+125 -138
appview/pages/templates/repo/index.html
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 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"> 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 </a> 27 </div> 28 </div> 29 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 34 {{ end }} 35 36 {{ define "repoLanguages" }} 37 - <div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t"> 38 {{ range $value := .Languages }} 39 - <div 40 - title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 41 - class="h-[4px] rounded-full" 42 - style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 43 - ></div> 44 {{ end }} 45 - </div> 46 {{ end }} 47 - 48 49 {{ define "branchSelector" }} 50 - <div class="flex gap-2 items-center items-stretch justify-center"> 51 - <select 52 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 53 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 54 - > 55 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 56 - {{ range .Branches }} 57 - <option 58 - value="{{ .Reference.Name }}" 59 - class="py-1" 60 - {{ if eq .Reference.Name $.Ref }} 61 - selected 62 - {{ end }} 63 - > 64 - {{ .Reference.Name }} 65 - </option> 66 - {{ end }} 67 - </optgroup> 68 - <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 69 - {{ range .Tags }} 70 - <option 71 - value="{{ .Reference.Name }}" 72 - class="py-1" 73 - {{ if eq .Reference.Name $.Ref }} 74 - selected 75 - {{ end }} 76 - > 77 - {{ .Reference.Name }} 78 - </option> 79 - {{ else }} 80 - <option class="py-1" disabled>no tags found</option> 81 - {{ end }} 82 - </optgroup> 83 - </select> 84 - <div class="flex items-center gap-2"> 85 - {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 86 - {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 87 - {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 88 - {{ $disabled := "" }} 89 - {{ $title := "" }} 90 - {{ if eq .ForkInfo.Status 0 }} 91 - {{ $disabled = "disabled" }} 92 - {{ $title = "This branch is not behind the upstream" }} 93 - {{ else if eq .ForkInfo.Status 2 }} 94 - {{ $disabled = "disabled" }} 95 - {{ $title = "This branch has conflicts that must be resolved" }} 96 - {{ else if eq .ForkInfo.Status 3 }} 97 - {{ $disabled = "disabled" }} 98 - {{ $title = "This branch does not exist on the upstream" }} 99 - {{ end }} 100 101 - <button 102 - id="syncBtn" 103 - {{ $disabled }} 104 - {{ if $title }}title="{{ $title }}"{{ end }} 105 - class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 106 - hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 107 - hx-trigger="click" 108 - hx-swap="none" 109 - > 110 - {{ if $disabled }} 111 - {{ i "refresh-cw-off" "w-4 h-4" }} 112 - {{ else }} 113 - {{ i "refresh-cw" "w-4 h-4" }} 114 - {{ end }} 115 - <span>sync</span> 116 - </button> 117 - {{ end }} 118 - <a 119 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 120 - class="btn flex items-center gap-2 no-underline hover:no-underline" 121 - title="Compare branches or tags" 122 - > 123 - {{ i "git-compare" "w-4 h-4" }} 124 - </a> 125 </div> 126 - </div> 127 {{ end }} 128 129 {{ define "fileTree" }} ··· 131 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 133 {{ range .Files }} 134 - <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 - <div class="col-span-1"> 136 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 {{ $icon := "folder" }} 138 {{ $iconStyle := "size-4 fill-current" }} ··· 144 {{ end }} 145 <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 <div class="flex items-center gap-2"> 147 - {{ i $icon $iconStyle }}{{ .Name }} 148 </div> 149 </a> 150 </div> 151 152 - <div class="text-xs col-span-1 text-right"> 153 {{ with .LastCommit }} 154 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 {{ end }} ··· 170 {{ define "commitLog" }} 171 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 172 <div class="flex justify-between items-center"> 173 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 174 - <div class="flex gap-2 items-center font-bold"> 175 - {{ i "logs" "w-4 h-4" }} commits 176 - </div> 177 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 178 - view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 179 - </span> 180 </a> 181 </div> 182 <div class="flex flex-col gap-6"> ··· 214 </div> 215 216 <!-- commit info bar --> 217 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 218 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 219 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 220 {{ if $verified }} ··· 278 {{ define "branchList" }} 279 {{ if gt (len .BranchesTrunc) 0 }} 280 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 281 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 282 - <div class="flex gap-2 items-center font-bold"> 283 - {{ i "git-branch" "w-4 h-4" }} branches 284 - </div> 285 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 286 - view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 287 - </span> 288 </a> 289 <div class="flex flex-col gap-1"> 290 {{ range .BranchesTrunc }} 291 - <div class="text-base flex items-center justify-between"> 292 - <div class="flex items-center gap-2"> 293 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 294 - class="inline no-underline hover:underline dark:text-white"> 295 {{ .Reference.Name }} 296 </a> 297 {{ if .Commit }} 298 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 299 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 300 {{ end }} 301 {{ if .IsDefault }} 302 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 303 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 304 {{ end }} 305 </div> 306 {{ if ne $.Ref .Reference.Name }} 307 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 308 - class="text-xs flex gap-2 items-center" 309 title="Compare branches or tags"> 310 {{ i "git-compare" "w-3 h-3" }} compare 311 </a> 312 - {{end}} 313 </div> 314 {{ end }} 315 </div> ··· 321 {{ if gt (len .TagsTrunc) 0 }} 322 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 323 <div class="flex justify-between items-center"> 324 - <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 325 - <div class="flex gap-2 items-center font-bold"> 326 - {{ i "tags" "w-4 h-4" }} tags 327 - </div> 328 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 329 - view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 330 - </span> 331 </a> 332 </div> 333 <div class="flex flex-col gap-1"> ··· 359 360 {{ define "repoAfter" }} 361 {{- if or .HTMLReadme .Readme -}} 362 - <section 363 - 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 }} 364 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 365 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 366 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 367 - {{ end }}" 368 - > 369 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 370 - {{- .Readme -}} 371 - </pre> 372 - {{- else -}} 373 - {{ .HTMLReadme }} 374 - {{- end -}}</article> 375 - </section> 376 {{- end -}} 377 - 378 - {{ template "repo/fragments/cloneInstructions" . }} 379 {{ end }}
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 16 {{ block "branchSelector" . }}{{ end }} 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 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 </a> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 </a> 27 + {{ template "repo/fragments/cloneDropdown" . }} 28 </div> 29 </div> 30 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 38 + <details class="group -m-6 mb-4"> 39 + <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 {{ range $value := .Languages }} 41 + <div 42 + title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 43 + style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 + ></div> 45 {{ end }} 46 + </summary> 47 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 + {{ range $value := .Languages }} 49 + <div 50 + class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 + > 52 + {{ template "repo/fragments/languageBall" $value.Name }} 53 + <div>{{ or $value.Name "Other" }} 54 + <span class="text-gray-500 dark:text-gray-400"> 55 + {{ if lt $value.Percentage 0.05 }} 56 + 0.1% 57 + {{ else }} 58 + {{ printf "%.1f" $value.Percentage }}% 59 + {{ end }} 60 + </span></div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </details> 65 {{ end }} 66 67 {{ define "branchSelector" }} 68 + <div class="flex gap-2 items-center justify-between w-full"> 69 + <div class="flex gap-2 items-center"> 70 + <select 71 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 73 + > 74 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 75 + {{ range .Branches }} 76 + <option 77 + value="{{ .Reference.Name }}" 78 + class="py-1" 79 + {{ if eq .Reference.Name $.Ref }} 80 + selected 81 + {{ end }} 82 + > 83 + {{ .Reference.Name }} 84 + </option> 85 + {{ end }} 86 + </optgroup> 87 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 88 + {{ range .Tags }} 89 + <option 90 + value="{{ .Reference.Name }}" 91 + class="py-1" 92 + {{ if eq .Reference.Name $.Ref }} 93 + selected 94 + {{ end }} 95 + > 96 + {{ .Reference.Name }} 97 + </option> 98 + {{ else }} 99 + <option class="py-1" disabled>no tags found</option> 100 + {{ end }} 101 + </optgroup> 102 + </select> 103 + <div class="flex items-center gap-2"> 104 + <a 105 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 106 + class="btn flex items-center gap-2 no-underline hover:no-underline" 107 + title="Compare branches or tags" 108 + > 109 + {{ i "git-compare" "w-4 h-4" }} 110 + </a> 111 + </div> 112 + </div> 113 114 + <!-- Clone dropdown in top right --> 115 + <div class="hidden md:flex items-center "> 116 + {{ template "repo/fragments/cloneDropdown" . }} 117 </div> 118 + </div> 119 {{ end }} 120 121 {{ define "fileTree" }} ··· 123 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 124 125 {{ range .Files }} 126 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 127 + <div class="col-span-2"> 128 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 129 {{ $icon := "folder" }} 130 {{ $iconStyle := "size-4 fill-current" }} ··· 136 {{ end }} 137 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 <div class="flex items-center gap-2"> 139 + {{ i $icon $iconStyle "flex-shrink-0" }} 140 + <span class="truncate">{{ .Name }}</span> 141 </div> 142 </a> 143 </div> 144 145 + <div class="text-sm col-span-1 text-right"> 146 {{ with .LastCommit }} 147 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 148 {{ end }} ··· 163 {{ define "commitLog" }} 164 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 165 <div class="flex justify-between items-center"> 166 + <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"> 167 + {{ i "logs" "w-4 h-4" }} commits 168 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 169 </a> 170 </div> 171 <div class="flex flex-col gap-6"> ··· 203 </div> 204 205 <!-- commit info bar --> 206 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 207 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 208 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 209 {{ if $verified }} ··· 267 {{ define "branchList" }} 268 {{ if gt (len .BranchesTrunc) 0 }} 269 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 270 + <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"> 271 + {{ i "git-branch" "w-4 h-4" }} branches 272 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 273 </a> 274 <div class="flex flex-col gap-1"> 275 {{ range .BranchesTrunc }} 276 + <div class="text-base flex items-center justify-between overflow-hidden"> 277 + <div class="flex items-center gap-2 min-w-0 flex-1"> 278 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 279 + class="inline-block truncate no-underline hover:underline dark:text-white"> 280 {{ .Reference.Name }} 281 </a> 282 {{ if .Commit }} 283 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 284 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 285 {{ end }} 286 {{ if .IsDefault }} 287 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 288 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 289 {{ end }} 290 </div> 291 {{ if ne $.Ref .Reference.Name }} 292 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 293 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 294 title="Compare branches or tags"> 295 {{ i "git-compare" "w-3 h-3" }} compare 296 </a> 297 + {{ end }} 298 </div> 299 {{ end }} 300 </div> ··· 306 {{ if gt (len .TagsTrunc) 0 }} 307 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 308 <div class="flex justify-between items-center"> 309 + <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"> 310 + {{ i "tags" "w-4 h-4" }} tags 311 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 312 </a> 313 </div> 314 <div class="flex flex-col gap-1"> ··· 340 341 {{ define "repoAfter" }} 342 {{- if or .HTMLReadme .Readme -}} 343 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 344 + {{- if .ReadmeFileName -}} 345 + <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 346 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 347 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 348 + </div> 349 + {{- end -}} 350 + <section 351 + class="p-6 overflow-auto {{ if not .Raw }} 352 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 353 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 354 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 355 + {{ end }}" 356 + > 357 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 + {{- .Readme -}} 359 + </pre> 360 + {{- else -}} 361 + {{ .HTMLReadme }} 362 + {{- end -}}</article> 363 + </section> 364 + </div> 365 {{- end -}} 366 {{ end }}
+58
appview/pages/templates/repo/issues/fragments/commentList.html
···
··· 1 + {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-8"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListing" (list $ .) }} 5 + {{ end }} 6 + <div> 7 + {{ end }} 8 + 9 + {{ define "commentListing" }} 10 + {{ $root := index . 0 }} 11 + {{ $comment := index . 1 }} 12 + {{ $params := 13 + (dict 14 + "RepoInfo" $root.RepoInfo 15 + "LoggedInUser" $root.LoggedInUser 16 + "Issue" $root.Issue 17 + "Comment" $comment.Self) }} 18 + 19 + <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 20 + {{ template "topLevelComment" $params }} 21 + 22 + <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 23 + {{ range $index, $reply := $comment.Replies }} 24 + <div class="relative "> 25 + <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 27 + 28 + <div class="pl-2"> 29 + {{ 30 + template "replyComment" 31 + (dict 32 + "RepoInfo" $root.RepoInfo 33 + "LoggedInUser" $root.LoggedInUser 34 + "Issue" $root.Issue 35 + "Comment" $reply) 36 + }} 37 + </div> 38 + </div> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ define "topLevelComment" }} 47 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 + {{ template "repo/issues/fragments/issueCommentBody" . }} 50 + </div> 51 + {{ end }} 52 + 53 + {{ define "replyComment" }} 54 + <div class="p-4 w-full mx-auto overflow-hidden"> 55 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 + {{ template "repo/issues/fragments/issueCommentBody" . }} 57 + </div> 58 + {{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['ยท']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 41 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 48 </div> 49 - {{ end }} 50 {{ end }} 51
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 9 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 13 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 18 </div> 19 + {{ end }} 20 + 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 32 {{ end }} 33 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-59
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - {{ template "user/fragments/picHandleLink" $owner }} 7 - 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['ยท']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ if .Deleted }} 21 - deleted {{ template "repo/fragments/time" .Deleted }} 22 - {{ else if .Edited }} 23 - edited {{ template "repo/fragments/time" .Edited }} 24 - {{ else }} 25 - {{ template "repo/fragments/time" .Created }} 26 - {{ end }} 27 - </a> 28 - 29 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 30 - {{ if and $isCommentOwner (not .Deleted) }} 31 - <button 32 - class="btn px-2 py-1 text-sm" 33 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 34 - hx-swap="outerHTML" 35 - hx-target="#comment-container-{{.CommentId}}" 36 - > 37 - {{ i "pencil" "w-4 h-4" }} 38 - </button> 39 - <button 40 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 41 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 42 - hx-confirm="Are you sure you want to delete your comment?" 43 - hx-swap="outerHTML" 44 - hx-target="#comment-container-{{.CommentId}}" 45 - > 46 - {{ i "trash-2" "w-4 h-4" }} 47 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 48 - </button> 49 - {{ end }} 50 - 51 - </div> 52 - {{ if not .Deleted }} 53 - <div class="prose dark:prose-invert"> 54 - {{ .Body | markdown }} 55 - </div> 56 - {{ end }} 57 - </div> 58 - {{ end }} 59 - {{ end }}
···
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
··· 1 + {{ define "repo/issues/fragments/issueCommentActions" }} 2 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 3 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 4 + <div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2"> 5 + {{ template "edit" . }} 6 + {{ template "delete" . }} 7 + </div> 8 + {{ end }} 9 + {{ end }} 10 + 11 + {{ define "edit" }} 12 + <a 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 14 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 + hx-swap="outerHTML" 16 + hx-target="#comment-body-{{.Comment.Id}}"> 17 + {{ i "pencil" "size-3" }} 18 + edit 19 + </a> 20 + {{ end }} 21 + 22 + {{ define "delete" }} 23 + <a 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 25 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 + hx-confirm="Are you sure you want to delete your comment?" 27 + hx-swap="outerHTML" 28 + hx-target="#comment-body-{{.Comment.Id}}" 29 + > 30 + {{ i "trash-2" "size-3" }} 31 + delete 32 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </a> 34 + {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 5 + {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 + {{ template "editIssueComment" . }} 9 + {{ template "deleteIssueComment" . }} 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ end }} 32 + </a> 33 + {{ end }} 34 + 35 + {{ define "editIssueComment" }} 36 + <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 + hx-swap="outerHTML" 40 + hx-target="#comment-body-{{.Comment.Id}}"> 41 + {{ i "pencil" "size-3" }} 42 + </a> 43 + {{ end }} 44 + 45 + {{ define "deleteIssueComment" }} 46 + <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 + hx-confirm="Are you sure you want to delete your comment?" 50 + hx-swap="outerHTML" 51 + hx-target="#comment-body-{{.Comment.Id}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
···
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 38 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 39 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 40 + {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 41 + {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }} 42 + <button 43 + id="close-button" 44 + type="button" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 84 + } 85 + }); 86 + </script> 87 + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 88 + <button 89 + type="button" 90 + class="btn flex items-center gap-2" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 143 + </div> 144 + {{ end }} 145 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
···
··· 1 + {{ define "repo/issues/fragments/replyComment" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + id="reply-form-{{ .Comment.Id }}" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 + > 9 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 10 + <textarea 11 + id="reply-{{.Comment.Id}}-textarea" 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 21 + 22 + <input 23 + type="text" 24 + id="reply-to" 25 + name="reply-to" 26 + required 27 + value="{{ .Comment.AtUri }}" 28 + class="hidden" 29 + /> 30 + {{ template "replyActions" . }} 31 + </form> 32 + {{ end }} 33 + 34 + {{ define "replyActions" }} 35 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 36 + {{ template "cancel" . }} 37 + {{ template "reply" . }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ define "cancel" }} 42 + <button 43 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 44 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 45 + hx-target="#reply-form-{{ .Comment.Id }}" 46 + hx-swap="outerHTML"> 47 + {{ i "x" "size-4" }} 48 + cancel 49 + </button> 50 + {{ end }} 51 + 52 + {{ define "reply" }} 53 + <button 54 + id="reply-{{ .Comment.Id }}" 55 + type="submit" 56 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 57 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + reply 60 + </button> 61 + {{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
··· 1 + {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full py-2 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+95 -202
appview/pages/templates/repo/issues/issue.html
··· 9 {{ end }} 10 11 {{ define "repoContent" }} 12 - <header class="pb-4"> 13 - <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 15 - <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 - </h1> 17 - </header> 18 19 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 20 - {{ $icon := "ban" }} 21 - {{ if eq .State "open" }} 22 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 23 - {{ $icon = "circle-dot" }} 24 - {{ end }} 25 26 - <section class="mt-2"> 27 - <div class="inline-flex items-center gap-2"> 28 - <div id="state" 29 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .State }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 - opened by 35 - {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandleLink" $owner }} 37 - <span class="select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" .Issue.Created }} 39 - </span> 40 - </div> 41 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 47 48 - <div class="flex items-center gap-2 mt-2"> 49 - {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 - {{ range $kind := .OrderedReactionKinds }} 51 - {{ 52 - template "repo/fragments/reaction" 53 - (dict 54 - "Kind" $kind 55 - "Count" (index $.Reactions $kind) 56 - "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 58 - }} 59 - {{ end }} 60 - </div> 61 - </section> 62 {{ end }} 63 64 - {{ define "repoAfter" }} 65 - <section id="comments" class="my-2 mt-2 space-y-2 relative"> 66 - {{ range $index, $comment := .Comments }} 67 - <div 68 - id="comment-{{ .CommentId }}" 69 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 70 - {{ if gt $index 0 }} 71 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 - {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 74 - </div> 75 - {{ end }} 76 - </section> 77 78 - {{ block "newComment" . }} {{ end }} 79 80 {{ end }} 81 82 - {{ define "newComment" }} 83 - {{ if .LoggedInUser }} 84 - <form 85 - id="comment-form" 86 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 87 - hx-on::after-request="if(event.detail.successful) this.reset()" 88 - > 89 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 90 - <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 91 - {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 92 - </div> 93 - <textarea 94 - id="comment-textarea" 95 - name="body" 96 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 97 - placeholder="Add to the discussion. Markdown is supported." 98 - onkeyup="updateCommentForm()" 99 - ></textarea> 100 - <div id="issue-comment"></div> 101 - <div id="issue-action" class="error"></div> 102 - </div> 103 - 104 - <div class="flex gap-2 mt-2"> 105 - <button 106 - id="comment-button" 107 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 108 - type="submit" 109 - hx-disabled-elt="#comment-button" 110 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group" 111 - disabled 112 - > 113 - {{ i "message-square-plus" "w-4 h-4" }} 114 - comment 115 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 116 - </button> 117 - 118 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 119 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 120 - {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 121 - {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }} 122 - <button 123 - id="close-button" 124 - type="button" 125 - class="btn flex items-center gap-2" 126 - hx-indicator="#close-spinner" 127 - hx-trigger="click" 128 - > 129 - {{ i "ban" "w-4 h-4" }} 130 - close 131 - <span id="close-spinner" class="group"> 132 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 133 - </span> 134 - </button> 135 - <div 136 - id="close-with-comment" 137 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 138 - hx-trigger="click from:#close-button" 139 - hx-disabled-elt="#close-with-comment" 140 - hx-target="#issue-comment" 141 - hx-indicator="#close-spinner" 142 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 143 - hx-swap="none" 144 - > 145 - </div> 146 - <div 147 - id="close-issue" 148 - hx-disabled-elt="#close-issue" 149 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 150 - hx-trigger="click from:#close-button" 151 - hx-target="#issue-action" 152 - hx-indicator="#close-spinner" 153 - hx-swap="none" 154 - > 155 - </div> 156 - <script> 157 - document.addEventListener('htmx:configRequest', function(evt) { 158 - if (evt.target.id === 'close-with-comment') { 159 - const commentText = document.getElementById('comment-textarea').value.trim(); 160 - if (commentText === '') { 161 - evt.detail.parameters = {}; 162 - evt.preventDefault(); 163 - } 164 - } 165 - }); 166 - </script> 167 - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} 168 - <button 169 - type="button" 170 - class="btn flex items-center gap-2" 171 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 172 - hx-indicator="#reopen-spinner" 173 - hx-swap="none" 174 - > 175 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 176 - reopen 177 - <span id="reopen-spinner" class="group"> 178 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 179 - </span> 180 - </button> 181 - {{ end }} 182 - 183 - <script> 184 - function updateCommentForm() { 185 - const textarea = document.getElementById('comment-textarea'); 186 - const commentButton = document.getElementById('comment-button'); 187 - const closeButton = document.getElementById('close-button'); 188 - 189 - if (textarea.value.trim() !== '') { 190 - commentButton.removeAttribute('disabled'); 191 - } else { 192 - commentButton.setAttribute('disabled', ''); 193 - } 194 195 - if (closeButton) { 196 - if (textarea.value.trim() !== '') { 197 - closeButton.innerHTML = ` 198 - {{ i "ban" "w-4 h-4" }} 199 - <span>close with comment</span> 200 - <span id="close-spinner" class="group"> 201 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 202 - </span>`; 203 - } else { 204 - closeButton.innerHTML = ` 205 - {{ i "ban" "w-4 h-4" }} 206 - <span>close</span> 207 - <span id="close-spinner" class="group"> 208 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 209 - </span>`; 210 - } 211 - } 212 - } 213 214 - document.addEventListener('DOMContentLoaded', function() { 215 - updateCommentForm(); 216 - }); 217 - </script> 218 - </div> 219 - </form> 220 - {{ else }} 221 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 222 - <a href="/login" class="underline">login</a> to join the discussion 223 - </div> 224 - {{ end }} 225 - {{ end }}
··· 9 {{ end }} 10 11 {{ define "repoContent" }} 12 + <section id="issue-{{ .Issue.IssueId }}"> 13 + {{ template "issueHeader" .Issue }} 14 + {{ template "issueInfo" . }} 15 + {{ if .Issue.Body }} 16 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 + {{ end }} 18 + {{ template "issueReactions" . }} 19 + </section> 20 + {{ end }} 21 22 + {{ define "issueHeader" }} 23 + <header class="pb-2"> 24 + <h1 class="text-2xl"> 25 + {{ .Title | description }} 26 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 27 + </h1> 28 + </header> 29 + {{ end }} 30 31 + {{ define "issueInfo" }} 32 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 33 + {{ $icon := "ban" }} 34 + {{ if eq .Issue.State "open" }} 35 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 36 + {{ $icon = "circle-dot" }} 37 + {{ end }} 38 + <div class="inline-flex items-center gap-2"> 39 + <div id="state" 40 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 41 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 42 + <span class="text-white">{{ .Issue.State }}</span> 43 + </div> 44 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 45 + opened by 46 + {{ template "user/fragments/picHandleLink" .Issue.Did }} 47 + <span class="select-none before:content-['\00B7']"></span> 48 + {{ if .Issue.Edited }} 49 + edited {{ template "repo/fragments/time" .Issue.Edited }} 50 + {{ else }} 51 + {{ template "repo/fragments/time" .Issue.Created }} 52 + {{ end }} 53 + </span> 54 55 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 56 + {{ template "issueActions" . }} 57 + {{ end }} 58 + </div> 59 + <div id="issue-actions-error" class="error"></div> 60 + {{ end }} 61 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 65 {{ end }} 66 67 + {{ define "editIssue" }} 68 + <a 69 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 70 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 71 + hx-swap="innerHTML" 72 + hx-target="#issue-{{.Issue.IssueId}}"> 73 + {{ i "pencil" "size-3" }} 74 + </a> 75 + {{ end }} 76 77 + {{ define "deleteIssue" }} 78 + <a 79 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 80 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 81 + hx-confirm="Are you sure you want to delete your issue?" 82 + hx-swap="none"> 83 + {{ i "trash-2" "size-3" }} 84 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </a> 86 + {{ end }} 87 88 + {{ define "issueReactions" }} 89 + <div class="flex items-center gap-2 mt-2"> 90 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 + {{ range $kind := .OrderedReactionKinds }} 92 + {{ 93 + template "repo/fragments/reaction" 94 + (dict 95 + "Kind" $kind 96 + "Count" (index $.Reactions $kind) 97 + "IsReacted" (index $.UserReacted $kind) 98 + "ThreadAt" $.Issue.AtUri) 99 + }} 100 + {{ end }} 101 + </div> 102 {{ end }} 103 104 + {{ define "repoAfter" }} 105 + <div class="flex flex-col gap-4 mt-4"> 106 + {{ 107 + template "repo/issues/fragments/commentList" 108 + (dict 109 + "RepoInfo" $.RepoInfo 110 + "LoggedInUser" $.LoggedInUser 111 + "Issue" $.Issue 112 + "CommentList" $.Issue.CommentList) 113 + }} 114 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 117 + {{ end }} 118
+42 -45
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 67 - <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandleLink" $owner }} 70 - </span> 71 72 - <span class="before:content-['ยท']"> 73 - {{ template "repo/fragments/time" .Created }} 74 - </span> 75 76 - <span class="before:content-['ยท']"> 77 - {{ $s := "s" }} 78 - {{ if eq .Metadata.CommentCount 1 }} 79 - {{ $s = "" }} 80 - {{ end }} 81 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 82 - </span> 83 - </p> 84 </div> 85 - {{ end }} 86 - </div> 87 - 88 - {{ block "pagination" . }} {{ end }} 89 - 90 {{ end }} 91 92 {{ define "pagination" }}
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 75 + <span class="before:content-['ยท']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + </p> 83 + </div> 84 + {{ end }} 85 </div> 86 + {{ block "pagination" . }} {{ end }} 87 {{ end }} 88 89 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="mt-6 space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn-create flex items-center gap-2"> 27 - {{ i "circle-plus" "w-4 h-4" }} 28 - create issue 29 - <span id="create-pull-spinner" class="group"> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 32 - </button> 33 - </div> 34 - </div> 35 - <div id="issues" class="error"></div> 36 - </form> 37 {{ end }}
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + {{ template "repo/issues/fragments/putIssue" . }} 5 {{ end }}
+2 -2
appview/pages/templates/repo/log.html
··· 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">Date</div> 25 </div> 26 {{ range $index, $commit := .Commits }} 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} ··· 85 {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 {{ end }} 87 </div> 88 - <div class="align-top text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 </div> 90 {{ end }} 91 </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 }} ··· 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>
+60
appview/pages/templates/repo/needsUpgrade.html
···
··· 1 + {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 + {{ define "extrameta" }} 3 + {{ template "repo/fragments/meta" . }} 4 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 5 + {{ end }} 6 + {{ define "repoContent" }} 7 + <main> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600"> 10 + <!-- mimic the repo view here, placeholders are LLM generated --> 11 + <div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 12 + {{ $files := 13 + (list 14 + "src" 15 + "docs" 16 + "config" 17 + "lib" 18 + "index.html" 19 + "log.html" 20 + "needsUpgrade.html" 21 + "new.html" 22 + "tags.html" 23 + "tree.html") 24 + }} 25 + {{ range $files }} 26 + <span> 27 + {{ if (contains . ".") }} 28 + {{ i "file" "size-4 inline-flex" }} 29 + {{ else }} 30 + {{ i "folder" "size-4 inline-flex fill-current" }} 31 + {{ end }} 32 + 33 + {{ . }} 34 + </span> 35 + {{ end }} 36 + </div> 37 + <div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 38 + {{ $commits := 39 + (list 40 + "Fix authentication bug in login flow" 41 + "Add new dashboard widgets for metrics" 42 + "Implement real-time notifications system") 43 + }} 44 + {{ range $commits }} 45 + <div class="flex flex-col"> 46 + <span>{{ . }}</span> 47 + <span class="text-xs">{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + </div> 51 + </div> 52 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 53 + <div class="text-center"> 54 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 55 + The knot hosting this repository needs an upgrade. This repository is currently unavailable. 56 + </div> 57 + </div> 58 + </div> 59 + </main> 60 + {{ end }}
+2 -2
appview/pages/templates/repo/new.html
··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 - <span class="dark:text-white">{{ . }}</span> 53 </div> 54 {{ else }} 55 <p class="dark:text-white">No knots available.</p> ··· 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 {{ i "book-plus" "w-4 h-4" }} 65 create repo 66 - <span id="create-pull-spinner" class="group"> 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 </span> 69 </button>
··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 </div> 54 {{ else }} 55 <p class="dark:text-white">No knots available.</p> ··· 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 {{ i "book-plus" "w-4 h-4" }} 65 create repo 66 + <span id="spinner" class="group"> 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 </span> 69 </button>
+2 -2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 23 </div> 24 {{ else if $allFail }} 25 <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-600" }} 27 <span>0/{{ $total }}</span> 28 </div> 29 {{ else if $allTimeout }} 30 <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-400" }} 32 <span>0/{{ $total }}</span> 33 </div> 34 {{ else }}
··· 23 </div> 24 {{ else if $allFail }} 25 <div class="flex gap-1 items-center"> 26 + {{ i "x" "size-4 text-red-500" }} 27 <span>0/{{ $total }}</span> 28 </div> 29 {{ else if $allTimeout }} 30 <div class="flex gap-1 items-center"> 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 <span>0/{{ $total }}</span> 33 </div> 34 {{ else }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 {{ else if eq $kind "timeout" }} 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 23 {{ else }} 24 {{ $icon = "x" }} 25 {{ $color = "text-red-600 dark:text-red-500" }}
··· 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 {{ else if eq $kind "timeout" }} 21 {{ $icon = "clock-alert" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 {{ else }} 24 {{ $icon = "x" }} 25 {{ $color = "text-red-600 dark:text-red-500" }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 20 {{ define "sidebar" }} 21 {{ $active := .Workflow }} 22 {{ with .Pipeline }} 23 {{ $id := .Id }} 24 <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 25 {{ range $name, $all := .Statuses }} 26 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 27 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 29 {{ $lastStatus := $all.Latest }} 30 {{ $kind := $lastStatus.Status.String }} 31
··· 19 20 {{ define "sidebar" }} 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 + 26 {{ with .Pipeline }} 27 {{ $id := .Id }} 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"> 29 {{ range $name, $all := .Statuses }} 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"> 31 <div 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 33 {{ $lastStatus := $all.Latest }} 34 {{ $kind := $lastStatus.Status.String }} 35
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 19 > 20 <option disabled selected>select a fork</option> 21 {{ range .Forks }} 22 - <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Name }} 24 </option> 25 {{ end }} 26 </select>
··· 19 > 20 <option disabled selected>select a fork</option> 21 {{ range .Forks }} 22 + <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 + {{ .Did | resolve }}/{{ .Name }} 24 </option> 25 {{ end }} 26 </select>
+3 -3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 </h1> 7 </header> ··· 28 </div> 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 opened by 31 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandleLink" $owner }} 33 <span class="select-none before:content-['\00B7']"></span> 34 {{ template "repo/fragments/time" .Pull.Created }} 35 ··· 45 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 46 {{ if .Pull.IsForkBased }} 47 {{ if .Pull.PullSource.Repo }} 48 <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 {{- else -}} 50 <span class="italic">[deleted fork]</span>
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 + {{ .Pull.Title | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 </h1> 7 </header> ··· 28 </div> 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 opened by 31 + {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }} 32 <span class="select-none before:content-['\00B7']"></span> 33 {{ template "repo/fragments/time" .Pull.Created }} 34 ··· 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 {{ if .Pull.IsForkBased }} 46 {{ if .Pull.PullSource.Repo }} 47 + {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 {{- else -}} 50 <span class="italic">[deleted fork]</span>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 </div> 53 {{ end }} 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 56 </div> 57 </div> 58 </a>
··· 52 </div> 53 {{ end }} 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 </div> 57 </div> 58 </a>
+2 -2
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }} ··· 9 </div> 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 13 </span> 14 </div> 15
··· 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }} ··· 9 </div> 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 + {{ .Title | description }} 13 </span> 14 </div> 15
+3 -3
appview/pages/templates/repo/pulls/interdiff.html
··· 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 ··· 55 56 {{ define "footerLayout" }} 57 <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 59 </footer> 60 {{ end }} 61 ··· 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 </div> 71 - <div class="sticky top-0 flex-grow max-h-screen"> 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 </div> 74 {{end}}
··· 30 31 {{ define "topbarLayout" }} 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/fragments/topbar" . }} 34 </header> 35 {{ end }} 36 ··· 55 56 {{ define "footerLayout" }} 57 <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }} 61 ··· 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}}
+3 -3
appview/pages/templates/repo/pulls/patch.html
··· 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 ··· 61 62 {{ define "footerLayout" }} 63 <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 65 </footer> 66 {{ end }} 67 ··· 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 </div> 76 - <div class="sticky top-0 flex-grow max-h-screen"> 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 </div> 79 {{end}}
··· 36 37 {{ define "topbarLayout" }} 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/fragments/topbar" . }} 40 </header> 41 {{ end }} 42 ··· 61 62 {{ define "footerLayout" }} 63 <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/fragments/footer" . }} 65 </footer> 66 {{ end }} 67 ··· 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}}
+4 -5
appview/pages/templates/repo/pulls/pull.html
··· 47 <!-- round summary --> 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 - {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 51 {{ $re := "re" }} 52 {{ if eq .RoundNumber 0 }} 53 {{ $re = "" }} 54 {{ end }} 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by {{ template "user/fragments/picHandleLink" $owner }} 57 <span class="select-none before:content-['\00B7']"></span> 58 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 <span class="select-none before:content-['ยท']"></span> ··· 122 {{ end }} 123 </div> 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 126 {{ if gt (len .Body) 0 }} 127 <button 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} 153 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - {{ template "user/fragments/picHandleLink" $owner }} 156 <span class="before:content-['ยท']"></span> 157 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 </div>
··· 47 <!-- round summary --> 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 + {{ $owner := resolve $.Pull.OwnerDid }} 51 {{ $re := "re" }} 52 {{ if eq .RoundNumber 0 }} 53 {{ $re = "" }} 54 {{ end }} 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 + by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 57 <span class="select-none before:content-['\00B7']"></span> 58 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 <span class="select-none before:content-['ยท']"></span> ··· 122 {{ end }} 123 </div> 124 <div class="flex items-center"> 125 + <span>{{ .Title | description }}</span> 126 {{ if gt (len .Body) 0 }} 127 <button 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} 153 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 155 <span class="before:content-['ยท']"></span> 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> 157 </div>
+3 -4
appview/pages/templates/repo/pulls/pulls.html
··· 50 <div class="px-6 py-4 z-5"> 51 <div class="pb-2"> 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div> 57 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 {{ $icon := "ban" }} 61 ··· 76 </span> 77 78 <span class="ml-1"> 79 - {{ template "user/fragments/picHandleLink" $owner }} 80 </span> 81 82 <span class="before:content-['ยท']"> ··· 145 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 146 <div class="flex gap-2 items-center px-6"> 147 <div class="flex-grow min-w-0 w-full py-2"> 148 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 149 </div> 150 </div> 151 </a>
··· 50 <div class="px-6 py-4 z-5"> 51 <div class="pb-2"> 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 + {{ .Title | description }} 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div> 57 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 59 {{ $icon := "ban" }} 60 ··· 75 </span> 76 77 <span class="ml-1"> 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 79 </span> 80 81 <span class="before:content-['ยท']"> ··· 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 145 <div class="flex gap-2 items-center px-6"> 146 <div class="flex-grow min-w-0 w-full py-2"> 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 </div> 149 </div> 150 </a>
+3 -1
appview/pages/templates/repo/settings/general.html
··· 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 }} ··· 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 ··· 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" }}
··· 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 id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 </div> 13 </section> 14 {{ end }} ··· 23 unless you specify a different branch. 24 </p> 25 </div> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 27 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 28 <option value="" disabled selected > 29 Choose a default branch ··· 55 <button 56 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 57 type="button" 58 + hx-swap="none" 59 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 60 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 61 {{ i "trash-2" "size-4" }}
+33 -23
appview/pages/templates/repo/settings/pipelines.html
··· 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. Spindles can be 24 - 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 - <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"> 31 - <select 32 - id="spindle" 33 - name="spindle" 34 - required 35 - class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 36 - {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 37 - <option value="" disabled selected > 38 - Choose a spindle 39 - </option> 40 - {{ range $.Spindles }} 41 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 42 - {{ . }} 43 </option> 44 - {{ end }} 45 - </select> 46 - <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 47 - {{ i "check" "size-4" }} 48 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 - </button> 50 - </form> 51 </div> 52 {{ end }} 53 ··· 77 {{ end }} 78 79 {{ define "addSecretButton" }} 80 - <button 81 class="btn flex items-center gap-2" 82 popovertarget="add-secret-modal" 83 popovertargetaction="toggle">
··· 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 ··· 87 {{ end }} 88 89 {{ define "addSecretButton" }} 90 + <button 91 class="btn flex items-center gap-2" 92 popovertarget="add-secret-modal" 93 popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
···
+8 -2
appview/pages/templates/repo/tags.html
··· 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 {{ range $artifact := $artifacts }} 104 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 {{ template "repo/fragments/artifact" $args }} 106 {{ end }} 107 {{ if $isPushAllowed }} 108 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 {{ end }} 110 </div> 111 - {{ end }} 112 {{ end }} 113 114 {{ define "uploadArtifact" }}
··· 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 102 {{ range $artifact := $artifacts }} 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 104 {{ template "repo/fragments/artifact" $args }} 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> 114 {{ if $isPushAllowed }} 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 116 {{ end }} 117 </div> 118 {{ end }} 119 120 {{ define "uploadArtifact" }}
+8 -7
appview/pages/templates/repo/tree.html
··· 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 27 {{ range .BreadCrumbs }} 28 - <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 29 {{ end }} 30 </div> 31 <div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 32 {{ $stats := .TreeStats }} 33 34 - <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 35 {{ if eq $stats.NumFolders 1 }} 36 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 37 <span>{{ $stats.NumFolders }} folder</span> ··· 54 55 {{ range .Files }} 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 - <div class="col-span-6 md:col-span-3"> 58 - {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61 ··· 65 {{ end }} 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }}{{ .Name }} 69 </div> 70 </a> 71 </div> 72 73 - <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 74 {{ with .LastCommit }} 75 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 76 {{ end }} 77 </div> 78 79 - <div class="col-span-6 md:col-span-2 text-right"> 80 {{ with .LastCommit }} 81 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 82 {{ end }}
··· 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 27 {{ range .BreadCrumbs }} 28 + <a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 29 {{ end }} 30 </div> 31 <div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 32 {{ $stats := .TreeStats }} 33 34 + <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span> 35 {{ if eq $stats.NumFolders 1 }} 36 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 37 <span>{{ $stats.NumFolders }} folder</span> ··· 54 55 {{ range .Files }} 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" (pathEscape $.Ref) $.TreePath .Name }} 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61 ··· 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 }} 78 </div> 79 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 }}
-192
appview/pages/templates/settings.html
··· 1 - {{ define "title" }}settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="flex flex-col"> 8 - {{ block "profile" . }} {{ end }} 9 - {{ block "keys" . }} {{ end }} 10 - {{ block "emails" . }} {{ end }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - {{ if .LoggedInUser.Handle }} 19 - <dt class="font-bold">handle</dt> 20 - <dd>@{{ .LoggedInUser.Handle }}</dd> 21 - {{ end }} 22 - <dt class="font-bold">did</dt> 23 - <dd>{{ .LoggedInUser.Did }}</dd> 24 - <dt class="font-bold">pds</dt> 25 - <dd>{{ .LoggedInUser.Pds }}</dd> 26 - </dl> 27 - </section> 28 - {{ end }} 29 - 30 - {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 - <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range $index, $key := .PubKeys }} 36 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 - <div class="flex flex-col gap-1"> 38 - <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 - <p class="font-bold dark:text-white">{{ .Name }}</p> 41 - </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 - <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 - </div> 46 - </div> 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete key" 50 - hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 - hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 - > 53 - {{ i "trash-2" "w-5 h-5" }} 54 - <span class="hidden md:inline">delete</span> 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - </button> 57 - </div> 58 - {{ end }} 59 - </div> 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#add-sshkey-spinner" 63 - hx-swap="none" 64 - class="max-w-2xl mb-8 space-y-4" 65 - > 66 - <input 67 - type="text" 68 - id="name" 69 - name="name" 70 - placeholder="key name" 71 - required 72 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 - 74 - <input 75 - id="key" 76 - name="key" 77 - placeholder="ssh-rsa AAAAAA..." 78 - required 79 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 - 81 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 - <span>add key</span> 83 - <span id="add-sshkey-spinner" class="group"> 84 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 - </span> 86 - </button> 87 - 88 - <div id="settings-keys" class="error dark:text-red-400"></div> 89 - </form> 90 - </section> 91 - {{ end }} 92 - 93 - {{ define "emails" }} 94 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 - <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 - <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 - {{ range $index, $email := .Emails }} 99 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 - <div class="flex flex-col gap-2"> 101 - <div class="inline-flex items-center gap-4"> 102 - {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 - <p class="font-bold dark:text-white">{{ .Address }}</p> 104 - <div class="inline-flex items-center gap-1"> 105 - {{ if .Verified }} 106 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 107 - {{ else }} 108 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 109 - {{ end }} 110 - {{ if .Primary }} 111 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 112 - {{ end }} 113 - </div> 114 - </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 - </div> 117 - <div class="flex gap-2 items-center"> 118 - {{ if not .Verified }} 119 - <button 120 - class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 - hx-post="/settings/emails/verify/resend" 122 - hx-swap="none" 123 - href="#" 124 - hx-vals='{"email": "{{ .Address }}"}'> 125 - {{ i "rotate-cw" "w-5 h-5" }} 126 - <span class="hidden md:inline">resend</span> 127 - </button> 128 - {{ end }} 129 - {{ if and (not .Primary) .Verified }} 130 - <a 131 - class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 - hx-post="/settings/emails/primary" 133 - hx-swap="none" 134 - href="#" 135 - hx-vals='{"email": "{{ .Address }}"}'> 136 - set as primary 137 - </a> 138 - {{ end }} 139 - {{ if not .Primary }} 140 - <form 141 - hx-delete="/settings/emails" 142 - hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 - hx-indicator="#delete-email-{{ $index }}-spinner" 144 - > 145 - <input type="hidden" name="email" value="{{ .Address }}"> 146 - <button 147 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 - title="Delete email" 149 - type="submit" 150 - > 151 - {{ i "trash-2" "w-5 h-5" }} 152 - <span class="hidden md:inline">delete</span> 153 - <span id="delete-email-{{ $index }}-spinner" class="group"> 154 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 - </span> 156 - </button> 157 - </form> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }} 162 - </div> 163 - <form 164 - hx-put="/settings/emails" 165 - hx-swap="none" 166 - class="max-w-2xl mb-8 space-y-4" 167 - hx-indicator="#add-email-spinner" 168 - > 169 - <input 170 - type="email" 171 - id="email" 172 - name="email" 173 - placeholder="your@email.com" 174 - required 175 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 - > 177 - 178 - <button 179 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 - type="submit" 181 - > 182 - <span>add email</span> 183 - <span id="add-email-spinner" class="group"> 184 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 - </span> 186 - </button> 187 - 188 - <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 - <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 - </form> 191 - </section> 192 - {{ end }}
···
+2 -4
appview/pages/templates/spindles/dashboard.html
··· 42 <div> 43 <div class="flex justify-between items-center"> 44 <div class="flex items-center gap-2"> 45 - {{ i "user" "size-4" }} 46 - {{ $user := index $.DidHandleMap . }} 47 - <a href="/{{ $user }}">{{ $user }}</a> 48 </div> 49 {{ if ne $.LoggedInUser.Did . }} 50 {{ block "removeMemberButton" (list $ . ) }} {{ end }} ··· 109 hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 110 hx-swap="none" 111 hx-vals='{"member": "{{$member}}" }' 112 - hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?" 113 > 114 {{ i "user-minus" "w-4 h-4" }} 115 remove
··· 42 <div> 43 <div class="flex justify-between items-center"> 44 <div class="flex items-center gap-2"> 45 + {{ template "user/fragments/picHandleLink" . }} 46 </div> 47 {{ if ne $.LoggedInUser.Did . }} 48 {{ block "removeMemberButton" (list $ . ) }} {{ end }} ··· 107 hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 108 hx-swap="none" 109 hx-vals='{"member": "{{$member}}" }' 110 + hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 111 > 112 {{ i "user-minus" "w-4 h-4" }} 113 remove
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 id="add-member-{{ .Instance }}" 15 popover 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }} 20 21 - {{ define "addMemberPopover" }} 22 <form 23 hx-post="/spindles/{{ .Instance }}/add" 24 hx-indicator="#spinner"
··· 14 id="add-member-{{ .Instance }}" 15 popover 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }} 20 21 + {{ define "addSpindleMemberPopover" }} 22 <form 23 hx-post="/spindles/{{ .Instance }}/add" 24 hx-indicator="#spinner"
+17 -10
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 {{ define "spindles/fragments/spindleListing" }} 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "leftSide" . }} {{ end }} 4 - {{ block "rightSide" . }} {{ end }} 5 </div> 6 {{ end }} 7 8 - {{ define "leftSide" }} 9 {{ if .Verified }} 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 - {{ .Instance }} 13 <span class="text-gray-500"> 14 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 </span> ··· 25 {{ end }} 26 {{ end }} 27 28 - {{ define "rightSide" }} 29 <div id="right-side" class="flex gap-2"> 30 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 31 - {{ if .Verified }} 32 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 33 {{ template "spindles/fragments/addMemberModal" . }} 34 {{ else }} 35 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 36 - {{ block "retryButton" . }} {{ end }} 37 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 39 </div> 40 {{ end }} 41 42 - {{ define "deleteButton" }} 43 <button 44 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 title="Delete spindle" ··· 55 {{ end }} 56 57 58 - {{ define "retryButton" }} 59 <button 60 class="btn gap-2 group" 61 title="Retry spindle verification"
··· 1 {{ define "spindles/fragments/spindleListing" }} 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 </div> 6 {{ end }} 7 8 + {{ define "spindleLeftSide" }} 9 {{ if .Verified }} 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 15 <span class="text-gray-500"> 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 17 </span> ··· 27 {{ end }} 28 {{ end }} 29 30 + {{ define "spindleRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 + 34 + {{ if .NeedsUpgrade }} 35 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span> 36 + {{ block "spindleRetryButton" . }} {{ end }} 37 + {{ else if .Verified }} 38 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 39 {{ template "spindles/fragments/addMemberModal" . }} 40 {{ else }} 41 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 42 + {{ block "spindleRetryButton" . }} {{ end }} 43 {{ end }} 44 + 45 + {{ block "spindleDeleteButton" . }} {{ end }} 46 </div> 47 {{ end }} 48 49 + {{ define "spindleDeleteButton" }} 50 <button 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 52 title="Delete spindle" ··· 62 {{ end }} 63 64 65 + {{ define "spindleRetryButton" }} 66 <button 67 class="btn gap-2 group" 68 title="Retry spindle verification"
+10 -9
appview/pages/templates/spindles/index.html
··· 1 {{ define "title" }}spindles{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</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"> ··· 15 {{ end }} 16 17 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Spindles are small CI runners. 21 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 22 - Checkout the documentation if you're interested in self-hosting. 23 - </a> 24 </p> 25 - </section> 26 {{ end }} 27 28 {{ define "list" }}
··· 1 {{ define "title" }}spindles{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 9 + </span> 10 </div> 11 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 19 {{ end }} 20 21 {{ define "about" }} 22 + <section class="rounded flex items-center gap-2"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Spindles are small CI runners. 25 </p> 26 + </section> 27 {{ end }} 28 29 {{ define "list" }}
+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 }}
+13
appview/pages/templates/strings/put.html
···
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-2 mb-4"> 5 + {{ if eq .Action "new" }} 6 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 + <p class="">Store and share code snippets with ease.</p> 8 + {{ else }} 9 + <p class="text-xl font-bold dark:text-white">Edit string</p> 10 + {{ end }} 11 + </div> 12 + {{ template "strings/fragments/form" . }} 13 + {{ end }}
+85
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 "content" }} 12 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 13 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 + <div class="text-lg flex items-center justify-between"> 15 + <div> 16 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 17 + <span class="select-none">/</span> 18 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 + </div> 20 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 + <div class="flex gap-2 text-base"> 22 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 + hx-boost="true" 24 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 25 + {{ i "pencil" "size-4" }} 26 + <span class="hidden md:inline">edit</span> 27 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 28 + </a> 29 + <button 30 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 31 + title="Delete string" 32 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 33 + hx-swap="none" 34 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 35 + > 36 + {{ i "trash-2" "size-4" }} 37 + <span class="hidden md:inline">delete</span> 38 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 + </button> 40 + </div> 41 + {{ end }} 42 + </div> 43 + <span> 44 + {{ with .String.Description }} 45 + {{ . }} 46 + {{ end }} 47 + </span> 48 + </section> 49 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 + <span> 52 + {{ .String.Filename }} 53 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 54 + <span> 55 + {{ with .String.Edited }} 56 + edited {{ template "repo/fragments/shortTimeAgo" . }} 57 + {{ else }} 58 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 59 + {{ end }} 60 + </span> 61 + </span> 62 + <div> 63 + <span>{{ .Stats.LineCount }} lines</span> 64 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 65 + <span>{{ byteFmt .Stats.ByteCount }}</span> 66 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 67 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 68 + {{ if .RenderToggle }} 69 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 70 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 71 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 72 + </a> 73 + {{ end }} 74 + </div> 75 + </div> 76 + <div class="overflow-x-auto overflow-y-hidden relative"> 77 + {{ if .ShowRendered }} 78 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 79 + {{ else }} 80 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 81 + {{ end }} 82 + </div> 83 + {{ template "fragments/multiline-select" }} 84 + </section> 85 + {{ end }}
+61
appview/pages/templates/strings/timeline.html
···
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "content" }} 4 + {{ block "timeline" $ }}{{ end }} 5 + {{ end }} 6 + 7 + {{ define "timeline" }} 8 + <div> 9 + <div class="p-6"> 10 + <p class="text-xl font-bold dark:text-white">All strings</p> 11 + </div> 12 + 13 + <div class="flex flex-col gap-4"> 14 + {{ range $i, $s := .Strings }} 15 + <div class="relative"> 16 + {{ if ne $i 0 }} 17 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 18 + {{ end }} 19 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 20 + {{ template "stringCard" $s }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "stringCard" }} 29 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 + <div class="font-medium dark:text-white flex gap-2 items-center"> 31 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 32 + </div> 33 + {{ with .Description }} 34 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 35 + {{ . }} 36 + </div> 37 + {{ end }} 38 + 39 + {{ template "stringCardInfo" . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "stringCardInfo" }} 44 + {{ $stat := .Stats }} 45 + {{ $resolved := resolve .Did.String }} 46 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 + {{ template "user/fragments/picHandle" $resolved }} 49 + </a> 50 + <span class="select-none [&:before]:content-['ยท']"></span> 51 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 + <span class="select-none [&:before]:content-['ยท']"></span> 53 + {{ with .Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .Created }} 57 + {{ end }} 58 + </div> 59 + {{ end }} 60 + 61 +
+34
appview/pages/templates/timeline/fragments/hero.html
···
··· 1 + {{ define "timeline/fragments/hero" }} 2 + <div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 + <div class="flex flex-col gap-6"> 4 + <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 + 6 + <p class="text-lg"> 7 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 + </p> 9 + <p class="text-lg"> 10 + we envision a place where developers have complete ownership of their 11 + code, open source communities can freely self-govern and most 12 + importantly, coding can be social and fun again. 13 + </p> 14 + 15 + <div class="flex gap-6 items-center"> 16 + <a href="/signup" class="no-underline hover:no-underline "> 17 + <button class="btn-create flex gap-2 px-4 items-center"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </button> 20 + </a> 21 + </div> 22 + </div> 23 + 24 + <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="block"> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 + </a> 28 + <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 + Monorepo for Tangled, built in the open with the community. 30 + </figcaption> 31 + </figure> 32 + </div> 33 + {{ end }} 34 +
+116
appview/pages/templates/timeline/fragments/timeline.html
···
··· 1 + {{ define "timeline/fragments/timeline" }} 2 + <div class="py-4"> 3 + <div class="px-6 pb-4"> 4 + <p class="text-xl font-bold dark:text-white">Timeline</p> 5 + </div> 6 + 7 + <div class="flex flex-col gap-4"> 8 + {{ range $i, $e := .Timeline }} 9 + <div class="relative"> 10 + {{ if ne $i 0 }} 11 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 12 + {{ end }} 13 + {{ with $e }} 14 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 + {{ if .Repo }} 16 + {{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }} 17 + {{ else if .Star }} 18 + {{ template "timeline/fragments/starEvent" (list $ .Star) }} 19 + {{ else if .Follow }} 20 + {{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }} 21 + {{ end }} 22 + </div> 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + {{ define "timeline/fragments/repoEvent" }} 31 + {{ $root := index . 0 }} 32 + {{ $repo := index . 1 }} 33 + {{ $source := index . 2 }} 34 + {{ $userHandle := resolve $repo.Did }} 35 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 36 + {{ template "user/fragments/picHandleLink" $repo.Did }} 37 + {{ with $source }} 38 + {{ $sourceDid := resolve .Did }} 39 + forked 40 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 41 + {{ $sourceDid }}/{{ .Name }} 42 + </a> 43 + to 44 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 45 + {{ else }} 46 + created 47 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 48 + {{ $repo.Name }} 49 + </a> 50 + {{ end }} 51 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 52 + </div> 53 + {{ with $repo }} 54 + {{ template "user/fragments/repoCard" (list $root . true) }} 55 + {{ end }} 56 + {{ end }} 57 + 58 + {{ define "timeline/fragments/starEvent" }} 59 + {{ $root := index . 0 }} 60 + {{ $star := index . 1 }} 61 + {{ with $star }} 62 + {{ $starrerHandle := resolve .StarredByDid }} 63 + {{ $repoOwnerHandle := resolve .Repo.Did }} 64 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 65 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 66 + starred 67 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 68 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 69 + </a> 70 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 71 + </div> 72 + {{ with .Repo }} 73 + {{ template "user/fragments/repoCard" (list $root . true) }} 74 + {{ end }} 75 + {{ end }} 76 + {{ end }} 77 + 78 + {{ define "timeline/fragments/followEvent" }} 79 + {{ $root := index . 0 }} 80 + {{ $follow := index . 1 }} 81 + {{ $profile := index . 2 }} 82 + {{ $stat := index . 3 }} 83 + 84 + {{ $userHandle := resolve $follow.UserDid }} 85 + {{ $subjectHandle := resolve $follow.SubjectDid }} 86 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 87 + {{ template "user/fragments/picHandleLink" $userHandle }} 88 + followed 89 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 90 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 91 + </div> 92 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 93 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 94 + <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 95 + </div> 96 + 97 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 98 + <a href="/{{ $subjectHandle }}"> 99 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 100 + </a> 101 + {{ with $profile }} 102 + {{ with .Description }} 103 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 104 + {{ end }} 105 + {{ end }} 106 + {{ with $stat }} 107 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 108 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 109 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 110 + <span class="select-none after:content-['ยท']"></span> 111 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 112 + </div> 113 + {{ end }} 114 + </div> 115 + </div> 116 + {{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
···
··· 1 + {{ define "timeline/fragments/trending" }} 2 + <div class="w-full md:mx-0 py-4"> 3 + <div class="px-6 pb-4"> 4 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 5 + Trending 6 + {{ i "trending-up" "size-4 flex-shrink-0" }} 7 + </h3> 8 + </div> 9 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 10 + {{ range $index, $repo := .Repos }} 11 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 12 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 13 + </div> 14 + {{ else }} 15 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 17 + No trending repositories this week 18 + </div> 19 + </div> 20 + {{ end }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + 25 +
+90
appview/pages/templates/timeline/home.html
···
··· 1 + {{ define "title" }}tangled &middot; tightly-knit social coding{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="flex flex-col gap-4"> 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ template "features" . }} 15 + {{ template "timeline/fragments/trending" . }} 16 + {{ template "timeline/fragments/timeline" . }} 17 + <div class="flex justify-end"> 18 + <a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400"> 19 + view more 20 + {{ i "arrow-right" "size-4" }} 21 + </a> 22 + </div> 23 + </div> 24 + {{ end }} 25 + 26 + 27 + {{ define "feature" }} 28 + {{ $info := index . 0 }} 29 + {{ $bullets := index . 1 }} 30 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 31 + <div class="flex-1"> 32 + <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 33 + <ul class="leading-normal"> 34 + {{ range $bullets }} 35 + <li><p>{{ escapeHtml . }}</p></li> 36 + {{ end }} 37 + </ul> 38 + </div> 39 + <div class="flex-shrink-0 w-96 md:w-1/3"> 40 + <a href="{{ $info.image }}"> 41 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 42 + </a> 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "features" }} 48 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 49 + {{ template "feature" (list 50 + (dict 51 + "title" "lightweight git repo hosting" 52 + "image" "https://assets.tangled.network/what-is-tangled-repo.png" 53 + "alt" "A repository hosted on Tangled" 54 + ) 55 + (list 56 + "Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations." 57 + "Add friends to your knot or invite collaborators to your repository." 58 + "Guarded by fine-grained role-based access control." 59 + "Use SSH to push and pull." 60 + ) 61 + ) }} 62 + 63 + {{ template "feature" (list 64 + (dict 65 + "title" "improved pull request model" 66 + "image" "https://assets.tangled.network/pulls.png" 67 + "alt" "Round-based pull requests." 68 + ) 69 + (list 70 + "An intuitive and effective round-based pull request flow, with inter-diffing between rounds." 71 + "Stacked pull requests using Jujutsu's change IDs." 72 + "Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes." 73 + ) 74 + ) }} 75 + 76 + {{ template "feature" (list 77 + (dict 78 + "title" "run pipelines using spindles" 79 + "image" "https://assets.tangled.network/pipelines.png" 80 + "alt" "CI pipeline running on spindle" 81 + ) 82 + (list 83 + "Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners." 84 + "Natively supports Nix for package management." 85 + "Easily extended to support different execution backends." 86 + ) 87 + ) }} 88 + </div> 89 + {{ end }} 90 +
+18
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 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 + 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 18 + {{ end }}
-161
appview/pages/templates/timeline.html
··· 1 - {{ define "title" }}timeline{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline ยท tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 8 - {{ end }} 9 - 10 - {{ define "topbar" }} 11 - {{ template "layouts/topbar" $ }} 12 - {{ end }} 13 - 14 - {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 21 - {{ end }} 22 - 23 - {{ define "hero" }} 24 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 26 - 27 - <p class="text-lg"> 28 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 29 - </p> 30 - <p class="text-lg"> 31 - we envision a place where developers have complete ownership of their 32 - code, open source communities can freely self-govern and most 33 - importantly, coding can be social and fun again. 34 - </p> 35 - 36 - <div class="flex gap-6 items-center"> 37 - <a href="/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-4"> 53 - {{ range $i, $e := .Timeline }} 54 - <div class="relative"> 55 - {{ if ne $i 0 }} 56 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 57 - {{ end }} 58 - {{ with $e }} 59 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 60 - {{ if .Repo }} 61 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 62 - {{ else if .Star }} 63 - {{ block "starEvent" (list $ .Star) }} {{ end }} 64 - {{ else if .Follow }} 65 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 66 - {{ end }} 67 - </div> 68 - {{ end }} 69 - </div> 70 - {{ end }} 71 - </div> 72 - </div> 73 - {{ end }} 74 - 75 - {{ define "repoEvent" }} 76 - {{ $root := index . 0 }} 77 - {{ $repo := index . 1 }} 78 - {{ $source := index . 2 }} 79 - {{ $userHandle := index $root.DidHandleMap $repo.Did }} 80 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 81 - {{ template "user/fragments/picHandleLink" $userHandle }} 82 - {{ with $source }} 83 - forked 84 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline"> 85 - {{ index $root.DidHandleMap .Did }}/{{ .Name }} 86 - </a> 87 - to 88 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 89 - {{ else }} 90 - created 91 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 92 - {{ $repo.Name }} 93 - </a> 94 - {{ end }} 95 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 96 - </div> 97 - {{ with $repo }} 98 - {{ template "user/fragments/repoCard" (list $root . true) }} 99 - {{ end }} 100 - {{ end }} 101 - 102 - {{ define "starEvent" }} 103 - {{ $root := index . 0 }} 104 - {{ $star := index . 1 }} 105 - {{ with $star }} 106 - {{ $starrerHandle := index $root.DidHandleMap .StarredByDid }} 107 - {{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }} 108 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 109 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 110 - starred 111 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 112 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 113 - </a> 114 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 115 - </div> 116 - {{ with .Repo }} 117 - {{ template "user/fragments/repoCard" (list $root . true) }} 118 - {{ end }} 119 - {{ end }} 120 - {{ end }} 121 - 122 - 123 - {{ define "followEvent" }} 124 - {{ $root := index . 0 }} 125 - {{ $follow := index . 1 }} 126 - {{ $profile := index . 2 }} 127 - {{ $stat := index . 3 }} 128 - 129 - {{ $userHandle := index $root.DidHandleMap $follow.UserDid }} 130 - {{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }} 131 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 132 - {{ template "user/fragments/picHandleLink" $userHandle }} 133 - followed 134 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 135 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 136 - </div> 137 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 138 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 139 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 140 - </div> 141 - 142 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 143 - <a href="/{{ $subjectHandle }}"> 144 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 145 - </a> 146 - {{ with $profile }} 147 - {{ with .Description }} 148 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 149 - {{ end }} 150 - {{ end }} 151 - {{ with $stat }} 152 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 153 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 154 - <span id="followers">{{ .Followers }} followers</span> 155 - <span class="select-none after:content-['ยท']"></span> 156 - <span id="following">{{ .Following }} following</span> 157 - </div> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }}
···
+102
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 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 34 + </h1> 35 + <h2 class="text-center text-xl italic dark:text-white"> 36 + tightly-knit social coding. 37 + </h2> 38 + <form 39 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 40 + hx-post="/signup/complete" 41 + hx-swap="none" 42 + hx-disabled-elt="#complete-signup-button" 43 + > 44 + <div class="flex flex-col"> 45 + <label for="code">verification code</label> 46 + <input 47 + type="text" 48 + id="code" 49 + name="code" 50 + tabindex="1" 51 + required 52 + placeholder="tngl-sh-foo-bar" 53 + /> 54 + <span class="text-sm text-gray-500 mt-1"> 55 + Enter the code sent to your email. 56 + </span> 57 + </div> 58 + 59 + <div class="flex flex-col"> 60 + <label for="username">username</label> 61 + <input 62 + type="text" 63 + id="username" 64 + name="username" 65 + tabindex="2" 66 + required 67 + placeholder="jason" 68 + /> 69 + <span class="text-sm text-gray-500 mt-1"> 70 + Your complete handle will be of the form <code>user.tngl.sh</code>. 71 + </span> 72 + </div> 73 + 74 + <div class="flex flex-col"> 75 + <label for="password">password</label> 76 + <input 77 + type="password" 78 + id="password" 79 + name="password" 80 + tabindex="3" 81 + required 82 + /> 83 + <span class="text-sm text-gray-500 mt-1"> 84 + Choose a strong password for your account. 85 + </span> 86 + </div> 87 + 88 + <button 89 + class="btn-create w-full my-2 mt-6 text-base" 90 + type="submit" 91 + id="complete-signup-button" 92 + tabindex="4" 93 + > 94 + <span>complete signup</span> 95 + </button> 96 + </form> 97 + <p id="signup-error" class="error w-full"></p> 98 + <p id="signup-msg" class="dark:text-white w-full"></p> 99 + </main> 100 + </body> 101 + </html> 102 + {{ end }}
+18
appview/pages/templates/user/followers.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "followers" }} 10 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 + {{ range .Followers }} 13 + {{ template "user/fragments/followCard" . }} 14 + {{ else }} 15 + <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 + {{ end }} 17 + </div> 18 + {{ end }}
+18
appview/pages/templates/user/following.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "following" }} 10 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 + {{ range .Following }} 13 + {{ template "user/fragments/followCard" . }} 14 + {{ else }} 15 + <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 + {{ end }} 17 + </div> 18 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 - class="py-1 px-1 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 + class="p-2 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 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> 31 <div class="flex gap-1 items-center"> 32 {{ i "star" "size-4 fill-current" }} 33 <span>{{ .RepoStats.StarCount }}</span>
··· 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 29 <div class="flex justify-between items-center w-full"> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 <div class="flex gap-1 items-center"> 32 {{ i "star" "size-4 fill-current" }} 33 <span>{{ .RepoStats.StarCount }}</span>
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 {{ define "user/fragments/follow" }} 2 - <button id="followBtn" 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 {{ end }} 10 11 hx-trigger="click" 12 - hx-target="#followBtn" 13 hx-swap="outerHTML" 14 > 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
··· 1 {{ define "user/fragments/follow" }} 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 {{ end }} 10 11 hx-trigger="click" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 14 > 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
···
··· 1 + {{ define "user/fragments/followCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 + </div> 8 + 9 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 + <a href="/{{ $userIdent }}"> 11 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + </a> 13 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 + <span class="select-none after:content-['ยท']"></span> 18 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 + </div> 20 + </div> 21 + 22 + {{ if ne .FollowStatus.String "IsSelf" }} 23 + <div class="max-w-24"> 24 + {{ template "user/fragments/follow" . }} 25 + </div> 26 + {{ end }} 27 + </div> 28 + </div> 29 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/picHandle.html
··· 1 {{ define "user/fragments/picHandle" }} 2 <img 3 src="{{ tinyAvatar . }}" 4 - alt="{{ . }}" 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 {{ . | truncateAt30 }}
··· 1 {{ define "user/fragments/picHandle" }} 2 <img 3 src="{{ tinyAvatar . }}" 4 + alt="" 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 {{ . | truncateAt30 }}
+3 -2
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 {{ define "user/fragments/picHandleLink" }} 2 - <a href="/{{ . }}" class="flex items-center"> 3 - {{ template "user/fragments/picHandle" . }} 4 </a> 5 {{ end }}
··· 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 }}
+22 -20
appview/pages/templates/user/fragments/profileCard.html
··· 1 {{ define "user/fragments/profileCard" }} 2 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 <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 }}" /> 8 </div> 9 - {{ end }} 10 </div> 11 <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> 16 17 <div class="md:hidden"> 18 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 19 </div> 20 </div> 21 <div class="col-span-3 md:col-span-full"> ··· 28 {{ end }} 29 30 <div class="hidden md:block"> 31 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 32 </div> 33 34 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 41 {{ if .IncludeBluesky }} 42 <div class="flex items-center gap-2"> 43 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 44 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 45 </div> 46 {{ end }} 47 {{ range $link := .Links }} ··· 83 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 84 </div> 85 </div> 86 - </div> 87 {{ end }} 88 89 {{ define "followerFollowing" }} 90 - {{ $followers := index . 0 }} 91 - {{ $following := index . 1 }} 92 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 93 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 94 - <span id="followers">{{ $followers }} followers</span> 95 - <span class="select-none after:content-['ยท']"></span> 96 - <span id="following">{{ $following }} following</span> 97 - </div> 98 {{ end }} 99
··· 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 <div class="w-3/4 aspect-square relative"> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 </div> 8 </div> 9 <div class="col-span-2"> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ $userIdent }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ $userIdent }} 14 + </p> 15 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 17 18 <div class="md:hidden"> 19 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 20 </div> 21 </div> 22 <div class="col-span-3 md:col-span-full"> ··· 29 {{ end }} 30 31 <div class="hidden md:block"> 32 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 33 </div> 34 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 42 {{ if .IncludeBluesky }} 43 <div class="flex items-center gap-2"> 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 45 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 46 </div> 47 {{ end }} 48 {{ range $link := .Links }} ··· 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 </div> 86 </div> 87 {{ end }} 88 89 {{ define "followerFollowing" }} 90 + {{ $root := index . 0 }} 91 + {{ $userIdent := index . 1 }} 92 + {{ with $root }} 93 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 + <span class="select-none after:content-['ยท']"></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 + </div> 99 + {{ end }} 100 {{ end }} 101
+39 -34
appview/pages/templates/user/fragments/repoCard.html
··· 4 {{ $fullName := index . 2 }} 5 6 {{ with $repo }} 7 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 8 - <div class="font-medium dark:text-white flex gap-2 items-center"> 9 - {{- if $fullName -}} 10 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a> 11 - {{- else -}} 12 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a> 13 - {{- end -}} 14 </div> 15 - {{ with .Description }} 16 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 17 - {{ . }} 18 - </div> 19 - {{ end }} 20 21 - {{ if .RepoStats }} 22 - {{ block "repoStats" .RepoStats }} {{ end }} 23 - {{ end }} 24 </div> 25 {{ end }} 26 {{ end }} 27 28 {{ define "repoStats" }} 29 - <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 30 {{ with .Language }} 31 - <div class="flex gap-2 items-center text-sm"> 32 - <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 - <span>{{ . }}</span> 34 - </div> 35 {{ end }} 36 {{ with .StarCount }} 37 - <div class="flex gap-1 items-center text-sm"> 38 - {{ i "star" "w-3 h-3 fill-current" }} 39 - <span>{{ . }}</span> 40 - </div> 41 {{ end }} 42 {{ with .IssueCount.Open }} 43 - <div class="flex gap-1 items-center text-sm"> 44 - {{ i "circle-dot" "w-3 h-3" }} 45 - <span>{{ . }}</span> 46 - </div> 47 {{ end }} 48 {{ with .PullCount.Open }} 49 - <div class="flex gap-1 items-center text-sm"> 50 - {{ i "git-pull-request" "w-3 h-3" }} 51 - <span>{{ . }}</span> 52 - </div> 53 {{ end }} 54 </div> 55 {{ end }} 56 - 57 -
··· 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 + {{ template "repo/fragments/languageBall" . }} 40 + <span>{{ . }}</span> 41 + </div> 42 {{ end }} 43 {{ with .StarCount }} 44 + <div class="flex gap-1 items-center text-sm"> 45 + {{ i "star" "w-3 h-3 fill-current" }} 46 + <span>{{ . }}</span> 47 + </div> 48 {{ end }} 49 {{ with .IssueCount.Open }} 50 + <div class="flex gap-1 items-center text-sm"> 51 + {{ i "circle-dot" "w-3 h-3" }} 52 + <span>{{ . }}</span> 53 + </div> 54 {{ end }} 55 {{ with .PullCount.Open }} 56 + <div class="flex gap-1 items-center text-sm"> 57 + {{ i "git-pull-request" "w-3 h-3" }} 58 + <span>{{ . }}</span> 59 + </div> 60 {{ end }} 61 </div> 62 {{ end }}
+15 -35
appview/pages/templates/user/login.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="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 - /> 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>login &middot; tangled</title> 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 31 <main class="max-w-md px-6 -mt-4"> 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. ··· 51 name="handle" 52 tabindex="1" 53 required 54 /> 55 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 60 </span> 61 </div> 62 63 <button 64 - class="btn w-full my-2 mt-6" 65 type="submit" 66 id="login-button" 67 tabindex="3" ··· 70 </button> 71 </form> 72 <p class="text-sm text-gray-500"> 73 - Join our <a href="https://chat.tangled.sh">Discord</a> or 74 - IRC channel: 75 - <a href="https://web.libera.chat/#tangled" 76 - ><code>#tangled</code> on Libera Chat</a 77 - >. 78 </p> 79 <p id="login-msg" class="error w-full"></p> 80 </main> 81 </body>
··· 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="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &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="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 </h1> 19 <h2 class="text-center text-xl italic dark:text-white"> 20 tightly-knit social coding. ··· 33 name="handle" 34 tabindex="1" 35 required 36 + placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 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. 42 </span> 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 45 46 <button 47 + class="btn w-full my-2 mt-6 text-base " 48 type="submit" 49 id="login-button" 50 tabindex="3" ··· 53 </button> 54 </form> 55 <p class="text-sm text-gray-500"> 56 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 57 </p> 58 + 59 <p id="login-msg" class="error w-full"></p> 60 </main> 61 </body>
+269
appview/pages/templates/user/overview.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 5 + <div class="grid grid-cols-1 gap-4"> 6 + {{ block "ownRepos" . }}{{ end }} 7 + {{ block "collaboratingRepos" . }}{{ end }} 8 + </div> 9 + </div> 10 + <div class="md:col-span-4 order-3 md:order-3"> 11 + {{ block "profileTimeline" . }}{{ end }} 12 + </div> 13 + {{ end }} 14 + 15 + {{ define "profileTimeline" }} 16 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 + <div class="flex flex-col gap-4 relative"> 18 + {{ if .ProfileTimeline.IsEmpty }} 19 + <p class="dark:text-white">This user does not have any activity yet.</p> 20 + {{ end }} 21 + 22 + {{ with .ProfileTimeline }} 23 + {{ range $idx, $byMonth := .ByMonth }} 24 + {{ with $byMonth }} 25 + {{ if not .IsEmpty }} 26 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6"> 27 + <p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400"> 28 + {{ if eq $idx 0 }} 29 + this month 30 + {{ else }} 31 + {{$idx}} month{{if ne $idx 1}}s{{end}} ago 32 + {{ end }} 33 + </p> 34 + 35 + <div class="flex flex-col gap-1"> 36 + {{ block "repoEvents" .RepoEvents }} {{ end }} 37 + {{ block "issueEvents" .IssueEvents }} {{ end }} 38 + {{ block "pullEvents" .PullEvents }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }} 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "repoEvents" }} 49 + {{ if gt (len .) 0 }} 50 + <details> 51 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 52 + <div class="flex flex-wrap items-center gap-2"> 53 + {{ i "book-plus" "w-4 h-4" }} 54 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 55 + </div> 56 + </summary> 57 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 58 + {{ range . }} 59 + <div class="flex flex-wrap items-center justify-between gap-2"> 60 + <span class="flex items-center gap-2"> 61 + <span class="text-gray-500 dark:text-gray-400"> 62 + {{ if .Source }} 63 + {{ i "git-fork" "w-4 h-4" }} 64 + {{ else }} 65 + {{ i "book-plus" "w-4 h-4" }} 66 + {{ end }} 67 + </span> 68 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 69 + {{- .Repo.Name -}} 70 + </a> 71 + </span> 72 + 73 + {{ with .Repo.RepoStats }} 74 + {{ with .Language }} 75 + <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/languageBall" . }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{end }} 80 + {{end }} 81 + </div> 82 + {{ end }} 83 + </div> 84 + </details> 85 + {{ end }} 86 + {{ end }} 87 + 88 + {{ define "issueEvents" }} 89 + {{ $items := .Items }} 90 + {{ $stats := .Stats }} 91 + 92 + {{ if gt (len $items) 0 }} 93 + <details> 94 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 95 + <div class="flex flex-wrap items-center gap-2"> 96 + {{ i "circle-dot" "w-4 h-4" }} 97 + 98 + <div> 99 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 100 + </div> 101 + 102 + {{ if gt $stats.Open 0 }} 103 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 104 + {{$stats.Open}} open 105 + </span> 106 + {{ end }} 107 + 108 + {{ if gt $stats.Closed 0 }} 109 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 110 + {{$stats.Closed}} closed 111 + </span> 112 + {{ end }} 113 + 114 + </div> 115 + </summary> 116 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 117 + {{ range $items }} 118 + {{ $repoOwner := resolve .Repo.Did }} 119 + {{ $repoName := .Repo.Name }} 120 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 121 + 122 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 123 + {{ if .Open }} 124 + <span class="text-green-600 dark:text-green-500"> 125 + {{ i "circle-dot" "w-4 h-4" }} 126 + </span> 127 + {{ else }} 128 + <span class="text-gray-500 dark:text-gray-400"> 129 + {{ i "ban" "w-4 h-4" }} 130 + </span> 131 + {{ end }} 132 + <div class="flex-none min-w-8 text-right"> 133 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 134 + </div> 135 + <div class="break-words max-w-full"> 136 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 137 + {{ .Title -}} 138 + </a> 139 + on 140 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 141 + {{$repoUrl}} 142 + </a> 143 + </div> 144 + </div> 145 + {{ end }} 146 + </div> 147 + </details> 148 + {{ end }} 149 + {{ end }} 150 + 151 + {{ define "pullEvents" }} 152 + {{ $items := .Items }} 153 + {{ $stats := .Stats }} 154 + {{ if gt (len $items) 0 }} 155 + <details> 156 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 157 + <div class="flex flex-wrap items-center gap-2"> 158 + {{ i "git-pull-request" "w-4 h-4" }} 159 + 160 + <div> 161 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 162 + </div> 163 + 164 + {{ if gt $stats.Open 0 }} 165 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 166 + {{$stats.Open}} open 167 + </span> 168 + {{ end }} 169 + 170 + {{ if gt $stats.Merged 0 }} 171 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 172 + {{$stats.Merged}} merged 173 + </span> 174 + {{ end }} 175 + 176 + 177 + {{ if gt $stats.Closed 0 }} 178 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 179 + {{$stats.Closed}} closed 180 + </span> 181 + {{ end }} 182 + 183 + </div> 184 + </summary> 185 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 186 + {{ range $items }} 187 + {{ $repoOwner := resolve .Repo.Did }} 188 + {{ $repoName := .Repo.Name }} 189 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 190 + 191 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 192 + {{ if .State.IsOpen }} 193 + <span class="text-green-600 dark:text-green-500"> 194 + {{ i "git-pull-request" "w-4 h-4" }} 195 + </span> 196 + {{ else if .State.IsMerged }} 197 + <span class="text-purple-600 dark:text-purple-500"> 198 + {{ i "git-merge" "w-4 h-4" }} 199 + </span> 200 + {{ else }} 201 + <span class="text-gray-600 dark:text-gray-300"> 202 + {{ i "git-pull-request-closed" "w-4 h-4" }} 203 + </span> 204 + {{ end }} 205 + <div class="flex-none min-w-8 text-right"> 206 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 207 + </div> 208 + <div class="break-words max-w-full"> 209 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 210 + {{ .Title -}} 211 + </a> 212 + on 213 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 214 + {{$repoUrl}} 215 + </a> 216 + </div> 217 + </div> 218 + {{ end }} 219 + </div> 220 + </details> 221 + {{ end }} 222 + {{ end }} 223 + 224 + {{ define "ownRepos" }} 225 + <div> 226 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 228 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 + <span>PINNED REPOS</span> 230 + </a> 231 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 232 + <button 233 + hx-get="profile/edit-pins" 234 + hx-target="#all-repos" 235 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 236 + {{ i "pencil" "w-3 h-3" }} 237 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 238 + </button> 239 + {{ end }} 240 + </div> 241 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 242 + {{ range .Repos }} 243 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 244 + {{ template "user/fragments/repoCard" (list $ . false) }} 245 + </div> 246 + {{ else }} 247 + <p class="dark:text-white">This user does not have any pinned repos.</p> 248 + {{ end }} 249 + </div> 250 + </div> 251 + {{ end }} 252 + 253 + {{ define "collaboratingRepos" }} 254 + {{ if gt (len .CollaboratingRepos) 0 }} 255 + <div> 256 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 257 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 258 + {{ range .CollaboratingRepos }} 259 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 260 + {{ template "user/fragments/repoCard" (list $ . true) }} 261 + </div> 262 + {{ else }} 263 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 264 + {{ end }} 265 + </div> 266 + </div> 267 + {{ end }} 268 + {{ end }} 269 +
-325
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - <div class="grid grid-cols-1 gap-4"> 14 - {{ template "user/fragments/profileCard" .Card }} 15 - {{ block "punchcard" .Punchcard }} {{ end }} 16 - </div> 17 - </div> 18 - <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 - <div class="grid grid-cols-1 gap-4"> 20 - {{ block "ownRepos" . }}{{ end }} 21 - {{ block "collaboratingRepos" . }}{{ end }} 22 - </div> 23 - </div> 24 - <div class="md:col-span-4 order-3 md:order-3"> 25 - {{ block "profileTimeline" . }}{{ end }} 26 - </div> 27 - </div> 28 - {{ end }} 29 - 30 - {{ define "profileTimeline" }} 31 - <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 32 - <div class="flex flex-col gap-4 relative"> 33 - {{ with .ProfileTimeline }} 34 - {{ range $idx, $byMonth := .ByMonth }} 35 - {{ with $byMonth }} 36 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 - {{ if eq $idx 0 }} 38 - 39 - {{ else }} 40 - {{ $s := "s" }} 41 - {{ if eq $idx 1 }} 42 - {{ $s = "" }} 43 - {{ end }} 44 - <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 - {{ end }} 46 - 47 - {{ if .IsEmpty }} 48 - <div class="text-gray-500 dark:text-gray-400"> 49 - No activity for this month 50 - </div> 51 - {{ else }} 52 - <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }} 54 - {{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }} 55 - {{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - 60 - {{ end }} 61 - {{ else }} 62 - <p class="dark:text-white">This user does not have any activity yet.</p> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 - 68 - {{ define "repoEvents" }} 69 - {{ $items := index . 0 }} 70 - {{ $handleMap := index . 1 }} 71 - 72 - {{ if gt (len $items) 0 }} 73 - <details> 74 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 - <div class="flex flex-wrap items-center gap-2"> 76 - {{ i "book-plus" "w-4 h-4" }} 77 - created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 78 - </div> 79 - </summary> 80 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 81 - {{ range $items }} 82 - <div class="flex flex-wrap items-center gap-2"> 83 - <span class="text-gray-500 dark:text-gray-400"> 84 - {{ if .Source }} 85 - {{ i "git-fork" "w-4 h-4" }} 86 - {{ else }} 87 - {{ i "book-plus" "w-4 h-4" }} 88 - {{ end }} 89 - </span> 90 - <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 91 - {{- .Repo.Name -}} 92 - </a> 93 - </div> 94 - {{ end }} 95 - </div> 96 - </details> 97 - {{ end }} 98 - {{ end }} 99 - 100 - {{ define "issueEvents" }} 101 - {{ $i := index . 0 }} 102 - {{ $items := $i.Items }} 103 - {{ $stats := $i.Stats }} 104 - {{ $handleMap := index . 1 }} 105 - 106 - {{ if gt (len $items) 0 }} 107 - <details> 108 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 109 - <div class="flex flex-wrap items-center gap-2"> 110 - {{ i "circle-dot" "w-4 h-4" }} 111 - 112 - <div> 113 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 114 - </div> 115 - 116 - {{ if gt $stats.Open 0 }} 117 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 118 - {{$stats.Open}} open 119 - </span> 120 - {{ end }} 121 - 122 - {{ if gt $stats.Closed 0 }} 123 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 124 - {{$stats.Closed}} closed 125 - </span> 126 - {{ end }} 127 - 128 - </div> 129 - </summary> 130 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 131 - {{ range $items }} 132 - {{ $repoOwner := index $handleMap .Metadata.Repo.Did }} 133 - {{ $repoName := .Metadata.Repo.Name }} 134 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 135 - 136 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 137 - {{ if .Open }} 138 - <span class="text-green-600 dark:text-green-500"> 139 - {{ i "circle-dot" "w-4 h-4" }} 140 - </span> 141 - {{ else }} 142 - <span class="text-gray-500 dark:text-gray-400"> 143 - {{ i "ban" "w-4 h-4" }} 144 - </span> 145 - {{ end }} 146 - <div class="flex-none min-w-8 text-right"> 147 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 148 - </div> 149 - <div class="break-words max-w-full"> 150 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 151 - {{ .Title -}} 152 - </a> 153 - on 154 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 155 - {{$repoUrl}} 156 - </a> 157 - </div> 158 - </div> 159 - {{ end }} 160 - </div> 161 - </details> 162 - {{ end }} 163 - {{ end }} 164 - 165 - {{ define "pullEvents" }} 166 - {{ $i := index . 0 }} 167 - {{ $items := $i.Items }} 168 - {{ $stats := $i.Stats }} 169 - {{ $handleMap := index . 1 }} 170 - {{ if gt (len $items) 0 }} 171 - <details> 172 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 173 - <div class="flex flex-wrap items-center gap-2"> 174 - {{ i "git-pull-request" "w-4 h-4" }} 175 - 176 - <div> 177 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 178 - </div> 179 - 180 - {{ if gt $stats.Open 0 }} 181 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 182 - {{$stats.Open}} open 183 - </span> 184 - {{ end }} 185 - 186 - {{ if gt $stats.Merged 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 188 - {{$stats.Merged}} merged 189 - </span> 190 - {{ end }} 191 - 192 - 193 - {{ if gt $stats.Closed 0 }} 194 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 195 - {{$stats.Closed}} closed 196 - </span> 197 - {{ end }} 198 - 199 - </div> 200 - </summary> 201 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 202 - {{ range $items }} 203 - {{ $repoOwner := index $handleMap .Repo.Did }} 204 - {{ $repoName := .Repo.Name }} 205 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 206 - 207 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 208 - {{ if .State.IsOpen }} 209 - <span class="text-green-600 dark:text-green-500"> 210 - {{ i "git-pull-request" "w-4 h-4" }} 211 - </span> 212 - {{ else if .State.IsMerged }} 213 - <span class="text-purple-600 dark:text-purple-500"> 214 - {{ i "git-merge" "w-4 h-4" }} 215 - </span> 216 - {{ else }} 217 - <span class="text-gray-600 dark:text-gray-300"> 218 - {{ i "git-pull-request-closed" "w-4 h-4" }} 219 - </span> 220 - {{ end }} 221 - <div class="flex-none min-w-8 text-right"> 222 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 223 - </div> 224 - <div class="break-words max-w-full"> 225 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 226 - {{ .Title -}} 227 - </a> 228 - on 229 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 230 - {{$repoUrl}} 231 - </a> 232 - </div> 233 - </div> 234 - {{ end }} 235 - </div> 236 - </details> 237 - {{ end }} 238 - {{ end }} 239 - 240 - {{ define "ownRepos" }} 241 - <div> 242 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 243 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 244 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 245 - <span>PINNED REPOS</span> 246 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 247 - view all {{ i "chevron-right" "w-4 h-4" }} 248 - </span> 249 - </a> 250 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 251 - <button 252 - hx-get="profile/edit-pins" 253 - hx-target="#all-repos" 254 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 255 - {{ i "pencil" "w-3 h-3" }} 256 - edit 257 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 258 - </button> 259 - {{ end }} 260 - </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 262 - {{ range .Repos }} 263 - {{ template "user/fragments/repoCard" (list $ . false) }} 264 - {{ else }} 265 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 266 - {{ end }} 267 - </div> 268 - </div> 269 - {{ end }} 270 - 271 - {{ define "collaboratingRepos" }} 272 - {{ if gt (len .CollaboratingRepos) 0 }} 273 - <div> 274 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 275 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 276 - {{ range .CollaboratingRepos }} 277 - {{ template "user/fragments/repoCard" (list $ . true) }} 278 - {{ else }} 279 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 280 - {{ end }} 281 - </div> 282 - </div> 283 - {{ end }} 284 - {{ end }} 285 - 286 - {{ define "punchcard" }} 287 - {{ $now := now }} 288 - <div> 289 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 290 - PUNCHCARD 291 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 292 - {{ .Total | int64 | commaFmt }} commits 293 - </span> 294 - </p> 295 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 296 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 297 - {{ range .Punches }} 298 - {{ $count := .Count }} 299 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 300 - {{ if lt $count 1 }} 301 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 302 - {{ else if lt $count 2 }} 303 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 304 - {{ else if lt $count 4 }} 305 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 306 - {{ else if lt $count 8 }} 307 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 308 - {{ else }} 309 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 310 - {{ end }} 311 - 312 - {{ if .Date.After $now }} 313 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 314 - {{ end }} 315 - <div class="w-full h-full flex justify-center items-center"> 316 - <div 317 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 318 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 319 - </div> 320 - </div> 321 - {{ end }} 322 - </div> 323 - </div> 324 - </div> 325 - {{ end }}
···
+7 -18
appview/pages/templates/user/repos.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 19 {{ end }} 20 21 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 26 {{ else }} 27 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 {{ end }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "ownRepos" }} 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 14 + </div> 15 {{ else }} 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 17 {{ end }}
+94
appview/pages/templates/user/settings/emails.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "emailSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "emailSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Commits authored using emails listed here will be associated with your Tangled profile. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + {{ template "addEmailButton" . }} 29 + </div> 30 + </div> 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + {{ range .Emails }} 33 + {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 + {{ else }} 35 + <div class="flex items-center justify-center p-2 text-gray-500"> 36 + no emails added yet 37 + </div> 38 + {{ end }} 39 + </div> 40 + {{ end }} 41 + 42 + {{ define "addEmailButton" }} 43 + <button 44 + class="btn flex items-center gap-2" 45 + popovertarget="add-email-modal" 46 + popovertargetaction="toggle"> 47 + {{ i "plus" "size-4" }} 48 + add email 49 + </button> 50 + <div 51 + id="add-email-modal" 52 + popover 53 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 + {{ template "addEmailModal" . }} 55 + </div> 56 + {{ end}} 57 + 58 + {{ define "addEmailModal" }} 59 + <form 60 + hx-put="/settings/emails" 61 + hx-indicator="#spinner" 62 + hx-swap="none" 63 + class="flex flex-col gap-2" 64 + > 65 + <p class="uppercase p-0">ADD EMAIL</p> 66 + <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 + <input 68 + type="email" 69 + id="email-address" 70 + name="email" 71 + required 72 + placeholder="your@email.com" 73 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 + /> 75 + <div class="flex gap-2 pt-2"> 76 + <button 77 + type="button" 78 + popovertarget="add-email-modal" 79 + popovertargetaction="hide" 80 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 + > 82 + {{ i "x" "size-4" }} cancel 83 + </button> 84 + <button type="submit" class="btn w-1/2 flex items-center"> 85 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 + <span id="spinner" class="group"> 87 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </span> 89 + </button> 90 + </div> 91 + <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 + <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 + </form> 94 + {{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
···
··· 1 + {{ define "user/settings/fragments/emailListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $email := index . 1 }} 4 + <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 + <span class="font-bold"> 9 + {{ $email.Address }} 10 + </span> 11 + <div class="inline-flex items-center gap-1"> 12 + {{ if $email.Verified }} 13 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 + {{ else }} 15 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 + {{ end }} 17 + {{ if $email.Primary }} 18 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 + {{ end }} 20 + </div> 21 + </div> 22 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 + <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 + </div> 25 + </div> 26 + <div class="flex gap-2 items-center"> 27 + {{ if not $email.Verified }} 28 + <button 29 + class="btn flex gap-2 text-sm px-2 py-1" 30 + hx-post="/settings/emails/verify/resend" 31 + hx-swap="none" 32 + hx-vals='{"email": "{{ $email.Address }}"}'> 33 + {{ i "rotate-cw" "w-4 h-4" }} 34 + <span class="hidden md:inline">resend</span> 35 + </button> 36 + {{ end }} 37 + {{ if and (not $email.Primary) $email.Verified }} 38 + <button 39 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 + hx-post="/settings/emails/primary" 41 + hx-swap="none" 42 + hx-vals='{"email": "{{ $email.Address }}"}'> 43 + set as primary 44 + </button> 45 + {{ end }} 46 + {{ if not $email.Primary }} 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete email" 50 + hx-delete="/settings/emails" 51 + hx-swap="none" 52 + hx-vals='{"email": "{{ $email.Address }}"}' 53 + hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 + > 55 + {{ i "trash-2" "w-5 h-5" }} 56 + <span class="hidden md:inline">delete</span> 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
···
··· 1 + {{ define "user/settings/fragments/keyListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $key := index . 1 }} 4 + <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + <span>{{ i "key" "w-4" "h-4" }}</span> 8 + <span class="font-bold"> 9 + {{ $key.Name }} 10 + </span> 11 + </div> 12 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 + {{ sshFingerprint $key.Key }} 14 + </span> 15 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 + <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 + </div> 18 + </div> 19 + <button 20 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 + title="Delete key" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 + hx-swap="none" 24 + hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 + > 26 + {{ i "trash-2" "w-5 h-5" }} 27 + <span class="hidden md:inline">delete</span> 28 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + </button> 30 + </div> 31 + {{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
···
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+101
appview/pages/templates/user/settings/keys.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "sshKeysSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "sshKeysSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + SSH public keys added here will be broadcasted to knots that you are a member of, 25 + allowing you to push to repositories there. 26 + </p> 27 + </div> 28 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 + {{ template "addKeyButton" . }} 30 + </div> 31 + </div> 32 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 + {{ range .PubKeys }} 34 + {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 + {{ else }} 36 + <div class="flex items-center justify-center p-2 text-gray-500"> 37 + no keys added yet 38 + </div> 39 + {{ end }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "addKeyButton" }} 44 + <button 45 + class="btn flex items-center gap-2" 46 + popovertarget="add-key-modal" 47 + popovertargetaction="toggle"> 48 + {{ i "plus" "size-4" }} 49 + add key 50 + </button> 51 + <div 52 + id="add-key-modal" 53 + popover 54 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 + {{ template "addKeyModal" . }} 56 + </div> 57 + {{ end}} 58 + 59 + {{ define "addKeyModal" }} 60 + <form 61 + hx-put="/settings/keys" 62 + hx-indicator="#spinner" 63 + hx-swap="none" 64 + class="flex flex-col gap-2" 65 + > 66 + <p class="uppercase p-0">ADD SSH KEY</p> 67 + <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 + <input 69 + type="text" 70 + id="key-name" 71 + name="name" 72 + required 73 + placeholder="key name" 74 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 + /> 76 + <textarea 77 + type="text" 78 + id="key-value" 79 + name="key" 80 + required 81 + placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 + <div class="flex gap-2 pt-2"> 84 + <button 85 + type="button" 86 + popovertarget="add-key-modal" 87 + popovertargetaction="hide" 88 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 + > 90 + {{ i "x" "size-4" }} cancel 91 + </button> 92 + <button type="submit" class="btn w-1/2 flex items-center"> 93 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 + <span id="spinner" class="group"> 95 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 + </span> 97 + </button> 98 + </div> 99 + <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 + </form> 101 + {{ end }}
+64
appview/pages/templates/user/settings/profile.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "profileInfo" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "profileInfo" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Your account information from your AT Protocol identity. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + </div> 29 + </div> 30 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 + <div class="flex items-center justify-between p-4"> 32 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 + <span>Handle</span> 35 + </div> 36 + {{ if .LoggedInUser.Handle }} 37 + <span class="font-bold"> 38 + @{{ .LoggedInUser.Handle }} 39 + </span> 40 + {{ end }} 41 + </div> 42 + </div> 43 + <div class="flex items-center justify-between p-4"> 44 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 46 + <span>Decentralized Identifier (DID)</span> 47 + </div> 48 + <span class="font-mono font-bold"> 49 + {{ .LoggedInUser.Did }} 50 + </span> 51 + </div> 52 + </div> 53 + <div class="flex items-center justify-between p-4"> 54 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 56 + <span>Personal Data Server (PDS)</span> 57 + </div> 58 + <span class="font-bold"> 59 + {{ .LoggedInUser.Pds }} 60 + </span> 61 + </div> 62 + </div> 63 + </div> 64 + {{ end }}
+55
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="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 19 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 20 + <form 21 + class="mt-4 max-w-sm mx-auto" 22 + hx-post="/signup" 23 + hx-swap="none" 24 + hx-disabled-elt="#signup-button" 25 + > 26 + <div class="flex flex-col mt-2"> 27 + <label for="email">email</label> 28 + <input 29 + type="email" 30 + id="email" 31 + name="email" 32 + tabindex="4" 33 + required 34 + placeholder="jason@bourne.co" 35 + /> 36 + </div> 37 + <span class="text-sm text-gray-500 mt-1"> 38 + You will receive an email with an invite code. Enter your 39 + invite code, desired username, and password in the next 40 + page to complete your registration. 41 + </span> 42 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 + <span>join now</span> 44 + </button> 45 + </form> 46 + <p class="text-sm text-gray-500"> 47 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 + </p> 49 + 50 + <p id="signup-msg" class="error w-full"></p> 51 + </main> 52 + </body> 53 + </html> 54 + {{ end }} 55 +
+19
appview/pages/templates/user/starred.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+45
appview/pages/templates/user/strings.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['ยท']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+34 -1
appview/posthog/notifier.go
··· 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(), ··· 129 log.Println("failed to enqueue posthog event:", err) 130 } 131 }
··· 58 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 Event: "new_issue", 63 Properties: posthog.Properties{ 64 "repo_at": issue.RepoAt.String(), ··· 129 log.Println("failed to enqueue posthog event:", err) 130 } 131 } 132 + 133 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 + err := n.client.Enqueue(posthog.Capture{ 135 + DistinctId: did, 136 + Event: "delete_string", 137 + Properties: posthog.Properties{"rkey": rkey}, 138 + }) 139 + if err != nil { 140 + log.Println("failed to enqueue posthog event:", err) 141 + } 142 + } 143 + 144 + func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) { 145 + err := n.client.Enqueue(posthog.Capture{ 146 + DistinctId: string.Did.String(), 147 + Event: "edit_string", 148 + Properties: posthog.Properties{"rkey": string.Rkey}, 149 + }) 150 + if err != nil { 151 + log.Println("failed to enqueue posthog event:", err) 152 + } 153 + } 154 + 155 + func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) { 156 + err := n.client.Enqueue(posthog.Capture{ 157 + DistinctId: string.Did.String(), 158 + Event: "create_string", 159 + Properties: posthog.Properties{"rkey": string.Rkey}, 160 + }) 161 + if err != nil { 162 + log.Println("failed to enqueue posthog event:", err) 163 + } 164 + }
+389 -270
appview/pulls/pulls.go
··· 5 "encoding/json" 6 "errors" 7 "fmt" 8 - "io" 9 "log" 10 "net/http" 11 "sort" ··· 19 "tangled.sh/tangled.sh/core/appview/notify" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - "github.com/bluesky-social/indigo/atproto/syntax" 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 "github.com/go-chi/chi/v5" 34 "github.com/google/uuid" 35 ) ··· 96 return 97 } 98 99 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 - resubmitResult = s.resubmitCheck(f, pull, stack) 103 } 104 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 151 } 152 } 153 154 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 155 - didHandleMap := make(map[string]string) 156 - for _, identity := range resolvedIds { 157 - if !identity.Handle.IsInvalidHandle() { 158 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 159 - } else { 160 - didHandleMap[identity.DID.String()] = identity.DID.String() 161 - } 162 - } 163 - 164 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 165 resubmitResult := pages.Unknown 166 if user != nil && user.Did == pull.OwnerDid { 167 - resubmitResult = s.resubmitCheck(f, pull, stack) 168 } 169 170 repoInfo := f.RepoInfo(user) ··· 212 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 213 LoggedInUser: user, 214 RepoInfo: repoInfo, 215 - DidHandleMap: didHandleMap, 216 Pull: pull, 217 Stack: stack, 218 AbandonedPulls: abandonedPulls, ··· 226 }) 227 } 228 229 - func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 230 if pull.State == db.PullMerged { 231 return types.MergeCheckResponse{} 232 } 233 234 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 235 - if err != nil { 236 - log.Printf("failed to get registration key: %v", err) 237 - return types.MergeCheckResponse{ 238 - Error: "failed to check merge status: this knot is unregistered", 239 - } 240 } 241 242 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 243 - if err != nil { 244 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 245 - return types.MergeCheckResponse{ 246 - Error: "failed to check merge status", 247 - } 248 } 249 250 patch := pull.LatestPatch() ··· 257 patch = mergeable.CombinedPatch() 258 } 259 260 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 261 - if err != nil { 262 - log.Println("failed to check for mergeability:", err) 263 return types.MergeCheckResponse{ 264 - Error: "failed to check merge status", 265 } 266 } 267 - switch resp.StatusCode { 268 - case 404: 269 - return types.MergeCheckResponse{ 270 - Error: "failed to check merge status: this knot does not support PRs", 271 - } 272 - case 400: 273 - return types.MergeCheckResponse{ 274 - Error: "failed to check merge status: does this knot support PRs?", 275 } 276 } 277 278 - respBody, err := io.ReadAll(resp.Body) 279 - if err != nil { 280 - log.Println("failed to read merge check response body") 281 - return types.MergeCheckResponse{ 282 - Error: "failed to check merge status: knot is not speaking the right language", 283 - } 284 } 285 - defer resp.Body.Close() 286 287 - var mergeCheckResponse types.MergeCheckResponse 288 - err = json.Unmarshal(respBody, &mergeCheckResponse) 289 - if err != nil { 290 - log.Println("failed to unmarshal merge check response", err) 291 - return types.MergeCheckResponse{ 292 - Error: "failed to check merge status: knot is not speaking the right language", 293 - } 294 } 295 296 - return mergeCheckResponse 297 } 298 299 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 300 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 301 return pages.Unknown 302 } ··· 318 // pulls within the same repo 319 knot = f.Knot 320 ownerDid = f.OwnerDid() 321 - repoName = f.RepoName 322 } 323 324 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 325 - if err != nil { 326 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 327 - return pages.Unknown 328 } 329 330 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 331 if err != nil { 332 log.Println("failed to reach knotserver", err) 333 return pages.Unknown 334 } 335 336 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 337 338 if pull.IsStacked() && stack != nil { ··· 340 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 341 } 342 343 - if latestSourceRev != result.Branch.Hash { 344 return pages.ShouldResubmit 345 } 346 ··· 377 return 378 } 379 380 - identsToResolve := []string{pull.OwnerDid} 381 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 382 - didHandleMap := make(map[string]string) 383 - for _, identity := range resolvedIds { 384 - if !identity.Handle.IsInvalidHandle() { 385 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 386 - } else { 387 - didHandleMap[identity.DID.String()] = identity.DID.String() 388 - } 389 - } 390 - 391 patch := pull.Submissions[roundIdInt].Patch 392 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 393 394 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 395 LoggedInUser: user, 396 - DidHandleMap: didHandleMap, 397 RepoInfo: f.RepoInfo(user), 398 Pull: pull, 399 Stack: stack, ··· 440 return 441 } 442 443 - identsToResolve := []string{pull.OwnerDid} 444 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 445 - didHandleMap := make(map[string]string) 446 - for _, identity := range resolvedIds { 447 - if !identity.Handle.IsInvalidHandle() { 448 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 449 - } else { 450 - didHandleMap[identity.DID.String()] = identity.DID.String() 451 - } 452 - } 453 - 454 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 455 if err != nil { 456 log.Println("failed to interdiff; current patch malformed") ··· 472 RepoInfo: f.RepoInfo(user), 473 Pull: pull, 474 Round: roundIdInt, 475 - DidHandleMap: didHandleMap, 476 Interdiff: interdiff, 477 DiffOpts: diffOpts, 478 }) ··· 494 return 495 } 496 497 - identsToResolve := []string{pull.OwnerDid} 498 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 499 - didHandleMap := make(map[string]string) 500 - for _, identity := range resolvedIds { 501 - if !identity.Handle.IsInvalidHandle() { 502 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 503 - } else { 504 - didHandleMap[identity.DID.String()] = identity.DID.String() 505 - } 506 - } 507 - 508 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 509 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 510 } ··· 529 530 pulls, err := db.GetPulls( 531 s.db, 532 - db.FilterEq("repo_at", f.RepoAt), 533 db.FilterEq("state", state), 534 ) 535 if err != nil { ··· 595 m[p.Sha] = p 596 } 597 598 - identsToResolve := make([]string, len(pulls)) 599 - for i, pull := range pulls { 600 - identsToResolve[i] = pull.OwnerDid 601 - } 602 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 603 - didHandleMap := make(map[string]string) 604 - for _, identity := range resolvedIds { 605 - if !identity.Handle.IsInvalidHandle() { 606 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 607 - } else { 608 - didHandleMap[identity.DID.String()] = identity.DID.String() 609 - } 610 - } 611 - 612 s.pages.RepoPulls(w, pages.RepoPullsParams{ 613 LoggedInUser: s.oauth.GetUser(r), 614 RepoInfo: f.RepoInfo(user), 615 Pulls: pulls, 616 - DidHandleMap: didHandleMap, 617 FilteringBy: state, 618 Stacks: stacks, 619 Pipelines: m, ··· 669 defer tx.Rollback() 670 671 createdAt := time.Now().Format(time.RFC3339) 672 - ownerDid := user.Did 673 674 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 675 if err != nil { 676 log.Println("failed to get pull at", err) 677 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 678 return 679 } 680 681 - atUri := f.RepoAt.String() 682 client, err := s.oauth.AuthorizedClient(r) 683 if err != nil { 684 log.Println("failed to get authorized client", err) ··· 691 Rkey: tid.TID(), 692 Record: &lexutil.LexiconTypeDecoder{ 693 Val: &tangled.RepoPullComment{ 694 - Repo: &atUri, 695 Pull: string(pullAt), 696 - Owner: &ownerDid, 697 Body: body, 698 CreatedAt: createdAt, 699 }, ··· 707 708 comment := &db.PullComment{ 709 OwnerDid: user.Did, 710 - RepoAt: f.RepoAt.String(), 711 PullId: pull.PullId, 712 Body: body, 713 CommentAt: atResp.Uri, ··· 746 747 switch r.Method { 748 case http.MethodGet: 749 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 750 if err != nil { 751 - log.Printf("failed to create unsigned client for %s", f.Knot) 752 - s.pages.Error503(w) 753 return 754 } 755 756 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 757 - if err != nil { 758 - log.Println("failed to fetch branches", err) 759 return 760 } 761 ··· 801 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 802 return 803 } 804 } 805 806 // Validate we have at least one valid PR creation method ··· 815 return 816 } 817 818 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 819 - if err != nil { 820 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 821 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 822 - return 823 } 824 825 - caps, err := us.Capabilities() 826 - if err != nil { 827 - log.Println("error fetching knot caps", f.Knot, err) 828 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 829 - return 830 - } 831 832 if !caps.PullRequests.FormatPatch { 833 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 869 sourceBranch string, 870 isStacked bool, 871 ) { 872 - // Generate a patch using /compare 873 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 874 - if err != nil { 875 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 876 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 877 - return 878 } 879 880 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 881 if err != nil { 882 log.Println("failed to compare", err) 883 s.pages.Notice(w, "pull", err.Error()) 884 return 885 } 886 887 sourceRev := comparison.Rev2 888 patch := comparison.Patch 889 ··· 913 } 914 915 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 916 - fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 917 if errors.Is(err, sql.ErrNoRows) { 918 s.pages.Notice(w, "pull", "No such fork.") 919 return ··· 923 return 924 } 925 926 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 927 - if err != nil { 928 - log.Println("failed to fetch registration key:", err) 929 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 930 - return 931 - } 932 933 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 934 - if err != nil { 935 - log.Println("failed to create signed client:", err) 936 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 937 return 938 } 939 940 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 941 - if err != nil { 942 - log.Println("failed to create unsigned client:", err) 943 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 944 - return 945 - } 946 - 947 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 948 - if err != nil { 949 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 950 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 951 - return 952 - } 953 - 954 - switch resp.StatusCode { 955 - case 404: 956 - case 400: 957 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 958 return 959 } 960 ··· 964 // hiddenRef: hidden/feature-1/main (on repo-fork) 965 // targetBranch: main (on repo-1) 966 // sourceBranch: feature-1 (on repo-fork) 967 - comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 968 if err != nil { 969 log.Println("failed to compare across branches", err) 970 s.pages.Notice(w, "pull", err.Error()) 971 return 972 } 973 ··· 979 return 980 } 981 982 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 983 - if err != nil { 984 - log.Println("failed to parse fork AT URI", err) 985 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 986 - return 987 - } 988 989 pullSource := &db.PullSource{ 990 Branch: sourceBranch, ··· 992 } 993 recordPullSource := &tangled.RepoPull_Source{ 994 Branch: sourceBranch, 995 - Repo: &fork.AtUri, 996 Sha: sourceRev, 997 } 998 ··· 1068 Body: body, 1069 TargetBranch: targetBranch, 1070 OwnerDid: user.Did, 1071 - RepoAt: f.RepoAt, 1072 Rkey: rkey, 1073 Submissions: []*db.PullSubmission{ 1074 &initialSubmission, ··· 1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1082 return 1083 } 1084 - pullId, err := db.NextPullId(tx, f.RepoAt) 1085 if err != nil { 1086 log.Println("failed to get pull id", err) 1087 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1094 Rkey: rkey, 1095 Record: &lexutil.LexiconTypeDecoder{ 1096 Val: &tangled.RepoPull{ 1097 - Title: title, 1098 - PullId: int64(pullId), 1099 - TargetRepo: string(f.RepoAt), 1100 - TargetBranch: targetBranch, 1101 - Patch: patch, 1102 - Source: recordPullSource, 1103 }, 1104 }, 1105 }) ··· 1267 return 1268 } 1269 1270 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1271 if err != nil { 1272 - log.Printf("failed to create unsigned client for %s", f.Knot) 1273 - s.pages.Error503(w) 1274 return 1275 } 1276 1277 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1278 - if err != nil { 1279 - log.Println("failed to reach knotserver", err) 1280 return 1281 } 1282 ··· 1330 } 1331 1332 forkVal := r.URL.Query().Get("fork") 1333 - 1334 // fork repo 1335 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1336 if err != nil { 1337 log.Println("failed to get repo", user.Did, forkVal) 1338 return 1339 } 1340 1341 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1342 - if err != nil { 1343 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1344 - s.pages.Error503(w) 1345 - return 1346 } 1347 1348 - sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1349 if err != nil { 1350 - log.Println("failed to reach knotserver for source branches", err) 1351 return 1352 } 1353 1354 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1355 - if err != nil { 1356 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1357 s.pages.Error503(w) 1358 return 1359 } 1360 1361 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1362 if err != nil { 1363 - log.Println("failed to reach knotserver for target branches", err) 1364 return 1365 } 1366 1367 - sourceBranches := sourceResult.Branches 1368 - sort.Slice(sourceBranches, func(i int, j int) bool { 1369 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1370 }) 1371 1372 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1373 RepoInfo: f.RepoInfo(user), 1374 - SourceBranches: sourceBranches, 1375 - TargetBranches: targetResult.Branches, 1376 }) 1377 } 1378 ··· 1467 return 1468 } 1469 1470 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1471 - if err != nil { 1472 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1473 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1474 - return 1475 } 1476 1477 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1478 if err != nil { 1479 log.Printf("compare request failed: %s", err) 1480 s.pages.Notice(w, "resubmit-error", err.Error()) 1481 return 1482 } 1483 ··· 1517 } 1518 1519 // extract patch by performing compare 1520 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1521 if err != nil { 1522 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1523 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1524 return 1525 } 1526 1527 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1528 - if err != nil { 1529 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1530 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1531 return 1532 } 1533 1534 // update the hidden tracking branch to latest 1535 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1536 if err != nil { 1537 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1538 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1539 return 1540 } 1541 1542 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1543 - if err != nil || resp.StatusCode != http.StatusNoContent { 1544 - log.Printf("failed to update tracking branch: %s", err) 1545 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1546 return 1547 } 1548 - 1549 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1550 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1551 - if err != nil { 1552 - log.Printf("failed to compare branches: %s", err) 1553 - s.pages.Notice(w, "resubmit-error", err.Error()) 1554 return 1555 } 1556 1557 sourceRev := comparison.Rev2 1558 patch := comparison.Patch ··· 1656 SwapRecord: ex.Cid, 1657 Record: &lexutil.LexiconTypeDecoder{ 1658 Val: &tangled.RepoPull{ 1659 - Title: pull.Title, 1660 - PullId: int64(pull.PullId), 1661 - TargetRepo: string(f.RepoAt), 1662 - TargetBranch: pull.TargetBranch, 1663 - Patch: patch, // new patch 1664 - Source: recordPullSource, 1665 }, 1666 }, 1667 }) ··· 1774 1775 // deleted pulls are marked as deleted in the DB 1776 for _, p := range deletions { 1777 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1778 if err != nil { 1779 log.Println("failed to delete pull", err, p.PullId) ··· 1813 for id := range updated { 1814 op, _ := origById[id] 1815 np, _ := newById[id] 1816 1817 submission := np.Submissions[np.LastRoundNumber()] 1818 ··· 1958 1959 patch := pullsToMerge.CombinedPatch() 1960 1961 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1962 - if err != nil { 1963 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1964 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1965 - return 1966 - } 1967 - 1968 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1969 if err != nil { 1970 log.Printf("resolving identity: %s", err) ··· 1977 log.Printf("failed to get primary email: %s", err) 1978 } 1979 1980 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1981 - if err != nil { 1982 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1983 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1984 - return 1985 } 1986 1987 - // Merge the pull request 1988 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1989 if err != nil { 1990 - log.Printf("failed to merge pull request: %s", err) 1991 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1992 return 1993 } 1994 1995 - if resp.StatusCode != http.StatusOK { 1996 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1997 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1998 return 1999 } 2000 ··· 2007 defer tx.Rollback() 2008 2009 for _, p := range pullsToMerge { 2010 - err := db.MergePull(tx, f.RepoAt, p.PullId) 2011 if err != nil { 2012 log.Printf("failed to update pull request status in database: %s", err) 2013 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2023 return 2024 } 2025 2026 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 2027 } 2028 2029 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2075 2076 for _, p := range pullsToClose { 2077 // Close the pull in the database 2078 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2079 if err != nil { 2080 log.Println("failed to close pull", err) 2081 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2143 2144 for _, p := range pullsToReopen { 2145 // Close the pull in the database 2146 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2147 if err != nil { 2148 log.Println("failed to close pull", err) 2149 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2195 Body: body, 2196 TargetBranch: targetBranch, 2197 OwnerDid: user.Did, 2198 - RepoAt: f.RepoAt, 2199 Rkey: rkey, 2200 Submissions: []*db.PullSubmission{ 2201 &initialSubmission,
··· 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log" 9 "net/http" 10 "sort" ··· 18 "tangled.sh/tangled.sh/core/appview/notify" 19 "tangled.sh/tangled.sh/core/appview/oauth" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 "tangled.sh/tangled.sh/core/idresolver" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 33 "github.com/go-chi/chi/v5" 34 "github.com/google/uuid" 35 ) ··· 96 return 97 } 98 99 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 } 104 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 151 } 152 } 153 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 } 159 160 repoInfo := f.RepoInfo(user) ··· 202 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 LoggedInUser: user, 204 RepoInfo: repoInfo, 205 Pull: pull, 206 Stack: stack, 207 AbandonedPulls: abandonedPulls, ··· 215 }) 216 } 217 218 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 if pull.State == db.PullMerged { 220 return types.MergeCheckResponse{} 221 } 222 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 228 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 231 } 232 233 patch := pull.LatestPatch() ··· 240 patch = mergeable.CombinedPatch() 241 } 242 243 + resp, xe := tangled.RepoMergeCheck( 244 + r.Context(), 245 + &xrpcc, 246 + &tangled.RepoMergeCheck_Input{ 247 + Did: f.OwnerDid(), 248 + Name: f.Name, 249 + Branch: pull.TargetBranch, 250 + Patch: patch, 251 + }, 252 + ) 253 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 + log.Println("failed to check for mergeability", "err", err) 255 return types.MergeCheckResponse{ 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 257 } 258 } 259 + 260 + // convert xrpc response to internal types 261 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 + for i, conflict := range resp.Conflicts { 263 + conflicts[i] = types.ConflictInfo{ 264 + Filename: conflict.Filename, 265 + Reason: conflict.Reason, 266 } 267 } 268 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 272 + } 273 + 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 276 } 277 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 280 } 281 282 + return result 283 } 284 285 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 return pages.Unknown 288 } ··· 304 // pulls within the same repo 305 knot = f.Knot 306 ownerDid = f.OwnerDid() 307 + repoName = f.Name 308 } 309 310 + scheme := "http" 311 + if !s.config.Core.Dev { 312 + scheme = "https" 313 + } 314 + host := fmt.Sprintf("%s://%s", scheme, knot) 315 + xrpcc := &indigoxrpc.Client{ 316 + Host: host, 317 } 318 319 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 320 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 321 if err != nil { 322 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 323 + log.Println("failed to call XRPC repo.branches", xrpcerr) 324 + return pages.Unknown 325 + } 326 log.Println("failed to reach knotserver", err) 327 return pages.Unknown 328 } 329 330 + targetBranch := branchResp 331 + 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 333 334 if pull.IsStacked() && stack != nil { ··· 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 337 } 338 339 + if latestSourceRev != targetBranch.Hash { 340 return pages.ShouldResubmit 341 } 342 ··· 373 return 374 } 375 376 patch := pull.Submissions[roundIdInt].Patch 377 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 378 379 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 380 LoggedInUser: user, 381 RepoInfo: f.RepoInfo(user), 382 Pull: pull, 383 Stack: stack, ··· 424 return 425 } 426 427 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 428 if err != nil { 429 log.Println("failed to interdiff; current patch malformed") ··· 445 RepoInfo: f.RepoInfo(user), 446 Pull: pull, 447 Round: roundIdInt, 448 Interdiff: interdiff, 449 DiffOpts: diffOpts, 450 }) ··· 466 return 467 } 468 469 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 470 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 471 } ··· 490 491 pulls, err := db.GetPulls( 492 s.db, 493 + db.FilterEq("repo_at", f.RepoAt()), 494 db.FilterEq("state", state), 495 ) 496 if err != nil { ··· 556 m[p.Sha] = p 557 } 558 559 s.pages.RepoPulls(w, pages.RepoPullsParams{ 560 LoggedInUser: s.oauth.GetUser(r), 561 RepoInfo: f.RepoInfo(user), 562 Pulls: pulls, 563 FilteringBy: state, 564 Stacks: stacks, 565 Pipelines: m, ··· 615 defer tx.Rollback() 616 617 createdAt := time.Now().Format(time.RFC3339) 618 619 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 620 if err != nil { 621 log.Println("failed to get pull at", err) 622 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 623 return 624 } 625 626 client, err := s.oauth.AuthorizedClient(r) 627 if err != nil { 628 log.Println("failed to get authorized client", err) ··· 635 Rkey: tid.TID(), 636 Record: &lexutil.LexiconTypeDecoder{ 637 Val: &tangled.RepoPullComment{ 638 Pull: string(pullAt), 639 Body: body, 640 CreatedAt: createdAt, 641 }, ··· 649 650 comment := &db.PullComment{ 651 OwnerDid: user.Did, 652 + RepoAt: f.RepoAt().String(), 653 PullId: pull.PullId, 654 Body: body, 655 CommentAt: atResp.Uri, ··· 688 689 switch r.Method { 690 case http.MethodGet: 691 + scheme := "http" 692 + if !s.config.Core.Dev { 693 + scheme = "https" 694 + } 695 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 696 + xrpcc := &indigoxrpc.Client{ 697 + Host: host, 698 + } 699 + 700 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 701 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 702 if err != nil { 703 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 704 + log.Println("failed to call XRPC repo.branches", xrpcerr) 705 + s.pages.Error503(w) 706 + return 707 + } 708 + log.Println("failed to fetch branches", err) 709 return 710 } 711 712 + var result types.RepoBranchesResponse 713 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 714 + log.Println("failed to decode XRPC response", err) 715 + s.pages.Error503(w) 716 return 717 } 718 ··· 758 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 759 return 760 } 761 + sanitizer := markup.NewSanitizer() 762 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 763 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 764 + return 765 + } 766 } 767 768 // Validate we have at least one valid PR creation method ··· 777 return 778 } 779 780 + // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 781 + // if err != nil { 782 + // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 783 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 784 + // return 785 + // } 786 + 787 + // TODO: make capabilities an xrpc call 788 + caps := struct { 789 + PullRequests struct { 790 + FormatPatch bool 791 + BranchSubmissions bool 792 + ForkSubmissions bool 793 + PatchSubmissions bool 794 + } 795 + }{ 796 + PullRequests: struct { 797 + FormatPatch bool 798 + BranchSubmissions bool 799 + ForkSubmissions bool 800 + PatchSubmissions bool 801 + }{ 802 + FormatPatch: true, 803 + BranchSubmissions: true, 804 + ForkSubmissions: true, 805 + PatchSubmissions: true, 806 + }, 807 } 808 809 + // caps, err := us.Capabilities() 810 + // if err != nil { 811 + // log.Println("error fetching knot caps", f.Knot, err) 812 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 813 + // return 814 + // } 815 816 if !caps.PullRequests.FormatPatch { 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 853 sourceBranch string, 854 isStacked bool, 855 ) { 856 + scheme := "http" 857 + if !s.config.Core.Dev { 858 + scheme = "https" 859 + } 860 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 861 + xrpcc := &indigoxrpc.Client{ 862 + Host: host, 863 } 864 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 867 if err != nil { 868 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 869 + log.Println("failed to call XRPC repo.compare", xrpcerr) 870 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 871 + return 872 + } 873 log.Println("failed to compare", err) 874 s.pages.Notice(w, "pull", err.Error()) 875 return 876 } 877 878 + var comparison types.RepoFormatPatchResponse 879 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 880 + log.Println("failed to decode XRPC compare response", err) 881 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 882 + return 883 + } 884 + 885 sourceRev := comparison.Rev2 886 patch := comparison.Patch 887 ··· 911 } 912 913 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 914 + repoString := strings.SplitN(forkRepo, "/", 2) 915 + forkOwnerDid := repoString[0] 916 + repoName := repoString[1] 917 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 918 if errors.Is(err, sql.ErrNoRows) { 919 s.pages.Notice(w, "pull", "No such fork.") 920 return ··· 924 return 925 } 926 927 + client, err := s.oauth.ServiceClient( 928 + r, 929 + oauth.WithService(fork.Knot), 930 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 931 + oauth.WithDev(s.config.Core.Dev), 932 + ) 933 934 + resp, err := tangled.RepoHiddenRef( 935 + r.Context(), 936 + client, 937 + &tangled.RepoHiddenRef_Input{ 938 + ForkRef: sourceBranch, 939 + RemoteRef: targetBranch, 940 + Repo: fork.RepoAt().String(), 941 + }, 942 + ) 943 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 944 + s.pages.Notice(w, "pull", err.Error()) 945 return 946 } 947 948 + if !resp.Success { 949 + errorMsg := "Failed to create pull request" 950 + if resp.Error != nil { 951 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 952 + } 953 + s.pages.Notice(w, "pull", errorMsg) 954 return 955 } 956 ··· 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 961 // targetBranch: main (on repo-1) 962 // sourceBranch: feature-1 (on repo-fork) 963 + forkScheme := "http" 964 + if !s.config.Core.Dev { 965 + forkScheme = "https" 966 + } 967 + forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 968 + forkXrpcc := &indigoxrpc.Client{ 969 + Host: forkHost, 970 + } 971 + 972 + forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 973 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 974 if err != nil { 975 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 976 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 977 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 978 + return 979 + } 980 log.Println("failed to compare across branches", err) 981 s.pages.Notice(w, "pull", err.Error()) 982 + return 983 + } 984 + 985 + var comparison types.RepoFormatPatchResponse 986 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 987 + log.Println("failed to decode XRPC compare response for fork", err) 988 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 989 return 990 } 991 ··· 997 return 998 } 999 1000 + forkAtUri := fork.RepoAt() 1001 + forkAtUriStr := forkAtUri.String() 1002 1003 pullSource := &db.PullSource{ 1004 Branch: sourceBranch, ··· 1006 } 1007 recordPullSource := &tangled.RepoPull_Source{ 1008 Branch: sourceBranch, 1009 + Repo: &forkAtUriStr, 1010 Sha: sourceRev, 1011 } 1012 ··· 1082 Body: body, 1083 TargetBranch: targetBranch, 1084 OwnerDid: user.Did, 1085 + RepoAt: f.RepoAt(), 1086 Rkey: rkey, 1087 Submissions: []*db.PullSubmission{ 1088 &initialSubmission, ··· 1095 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1096 return 1097 } 1098 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1099 if err != nil { 1100 log.Println("failed to get pull id", err) 1101 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1108 Rkey: rkey, 1109 Record: &lexutil.LexiconTypeDecoder{ 1110 Val: &tangled.RepoPull{ 1111 + Title: title, 1112 + Target: &tangled.RepoPull_Target{ 1113 + Repo: string(f.RepoAt()), 1114 + Branch: targetBranch, 1115 + }, 1116 + Patch: patch, 1117 + Source: recordPullSource, 1118 }, 1119 }, 1120 }) ··· 1282 return 1283 } 1284 1285 + scheme := "http" 1286 + if !s.config.Core.Dev { 1287 + scheme = "https" 1288 + } 1289 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1290 + xrpcc := &indigoxrpc.Client{ 1291 + Host: host, 1292 + } 1293 + 1294 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1295 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1296 if err != nil { 1297 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1298 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1299 + s.pages.Error503(w) 1300 + return 1301 + } 1302 + log.Println("failed to fetch branches", err) 1303 return 1304 } 1305 1306 + var result types.RepoBranchesResponse 1307 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1308 + log.Println("failed to decode XRPC response", err) 1309 + s.pages.Error503(w) 1310 return 1311 } 1312 ··· 1360 } 1361 1362 forkVal := r.URL.Query().Get("fork") 1363 + repoString := strings.SplitN(forkVal, "/", 2) 1364 + forkOwnerDid := repoString[0] 1365 + forkName := repoString[1] 1366 // fork repo 1367 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1368 if err != nil { 1369 log.Println("failed to get repo", user.Did, forkVal) 1370 return 1371 } 1372 1373 + sourceScheme := "http" 1374 + if !s.config.Core.Dev { 1375 + sourceScheme = "https" 1376 + } 1377 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1378 + sourceXrpcc := &indigoxrpc.Client{ 1379 + Host: sourceHost, 1380 } 1381 1382 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1383 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1384 if err != nil { 1385 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1386 + log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1387 + s.pages.Error503(w) 1388 + return 1389 + } 1390 + log.Println("failed to fetch source branches", err) 1391 return 1392 } 1393 1394 + // Decode source branches 1395 + var sourceBranches types.RepoBranchesResponse 1396 + if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1397 + log.Println("failed to decode source branches XRPC response", err) 1398 s.pages.Error503(w) 1399 return 1400 } 1401 1402 + targetScheme := "http" 1403 + if !s.config.Core.Dev { 1404 + targetScheme = "https" 1405 + } 1406 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1407 + targetXrpcc := &indigoxrpc.Client{ 1408 + Host: targetHost, 1409 + } 1410 + 1411 + targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1412 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1413 if err != nil { 1414 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1415 + log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1416 + s.pages.Error503(w) 1417 + return 1418 + } 1419 + log.Println("failed to fetch target branches", err) 1420 return 1421 } 1422 1423 + // Decode target branches 1424 + var targetBranches types.RepoBranchesResponse 1425 + if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1426 + log.Println("failed to decode target branches XRPC response", err) 1427 + s.pages.Error503(w) 1428 + return 1429 + } 1430 + 1431 + sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1432 + return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1433 }) 1434 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1436 RepoInfo: f.RepoInfo(user), 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1439 }) 1440 } 1441 ··· 1530 return 1531 } 1532 1533 + scheme := "http" 1534 + if !s.config.Core.Dev { 1535 + scheme = "https" 1536 + } 1537 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1538 + xrpcc := &indigoxrpc.Client{ 1539 + Host: host, 1540 } 1541 1542 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1543 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1544 if err != nil { 1545 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1546 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1547 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1548 + return 1549 + } 1550 log.Printf("compare request failed: %s", err) 1551 s.pages.Notice(w, "resubmit-error", err.Error()) 1552 + return 1553 + } 1554 + 1555 + var comparison types.RepoFormatPatchResponse 1556 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1557 + log.Println("failed to decode XRPC compare response", err) 1558 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1559 return 1560 } 1561 ··· 1595 } 1596 1597 // extract patch by performing compare 1598 + forkScheme := "http" 1599 + if !s.config.Core.Dev { 1600 + forkScheme = "https" 1601 + } 1602 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1603 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1604 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1605 if err != nil { 1606 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1607 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1608 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1609 + return 1610 + } 1611 + log.Printf("failed to compare branches: %s", err) 1612 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1613 return 1614 } 1615 1616 + var forkComparison types.RepoFormatPatchResponse 1617 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1618 + log.Println("failed to decode XRPC compare response for fork", err) 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1620 return 1621 } 1622 1623 // update the hidden tracking branch to latest 1624 + client, err := s.oauth.ServiceClient( 1625 + r, 1626 + oauth.WithService(forkRepo.Knot), 1627 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1628 + oauth.WithDev(s.config.Core.Dev), 1629 + ) 1630 if err != nil { 1631 + log.Printf("failed to connect to knot server: %v", err) 1632 return 1633 } 1634 1635 + resp, err := tangled.RepoHiddenRef( 1636 + r.Context(), 1637 + client, 1638 + &tangled.RepoHiddenRef_Input{ 1639 + ForkRef: pull.PullSource.Branch, 1640 + RemoteRef: pull.TargetBranch, 1641 + Repo: forkRepo.RepoAt().String(), 1642 + }, 1643 + ) 1644 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1645 + s.pages.Notice(w, "resubmit-error", err.Error()) 1646 return 1647 } 1648 + if !resp.Success { 1649 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1650 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1651 return 1652 } 1653 + 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1656 1657 sourceRev := comparison.Rev2 1658 patch := comparison.Patch ··· 1756 SwapRecord: ex.Cid, 1757 Record: &lexutil.LexiconTypeDecoder{ 1758 Val: &tangled.RepoPull{ 1759 + Title: pull.Title, 1760 + Target: &tangled.RepoPull_Target{ 1761 + Repo: string(f.RepoAt()), 1762 + Branch: pull.TargetBranch, 1763 + }, 1764 + Patch: patch, // new patch 1765 + Source: recordPullSource, 1766 }, 1767 }, 1768 }) ··· 1875 1876 // deleted pulls are marked as deleted in the DB 1877 for _, p := range deletions { 1878 + // do not do delete already merged PRs 1879 + if p.State == db.PullMerged { 1880 + continue 1881 + } 1882 + 1883 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1884 if err != nil { 1885 log.Println("failed to delete pull", err, p.PullId) ··· 1919 for id := range updated { 1920 op, _ := origById[id] 1921 np, _ := newById[id] 1922 + 1923 + // do not update already merged PRs 1924 + if op.State == db.PullMerged { 1925 + continue 1926 + } 1927 1928 submission := np.Submissions[np.LastRoundNumber()] 1929 ··· 2069 2070 patch := pullsToMerge.CombinedPatch() 2071 2072 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2073 if err != nil { 2074 log.Printf("resolving identity: %s", err) ··· 2081 log.Printf("failed to get primary email: %s", err) 2082 } 2083 2084 + authorName := ident.Handle.String() 2085 + mergeInput := &tangled.RepoMerge_Input{ 2086 + Did: f.OwnerDid(), 2087 + Name: f.Name, 2088 + Branch: pull.TargetBranch, 2089 + Patch: patch, 2090 + CommitMessage: &pull.Title, 2091 + AuthorName: &authorName, 2092 } 2093 2094 + if pull.Body != "" { 2095 + mergeInput.CommitBody = &pull.Body 2096 + } 2097 + 2098 + if email.Address != "" { 2099 + mergeInput.AuthorEmail = &email.Address 2100 + } 2101 + 2102 + client, err := s.oauth.ServiceClient( 2103 + r, 2104 + oauth.WithService(f.Knot), 2105 + oauth.WithLxm(tangled.RepoMergeNSID), 2106 + oauth.WithDev(s.config.Core.Dev), 2107 + ) 2108 if err != nil { 2109 + log.Printf("failed to connect to knot server: %v", err) 2110 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2111 return 2112 } 2113 2114 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 2115 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 2116 + s.pages.Notice(w, "pull-merge-error", err.Error()) 2117 return 2118 } 2119 ··· 2126 defer tx.Rollback() 2127 2128 for _, p := range pullsToMerge { 2129 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 2130 if err != nil { 2131 log.Printf("failed to update pull request status in database: %s", err) 2132 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2142 return 2143 } 2144 2145 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2146 } 2147 2148 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2194 2195 for _, p := range pullsToClose { 2196 // Close the pull in the database 2197 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2198 if err != nil { 2199 log.Println("failed to close pull", err) 2200 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2262 2263 for _, p := range pullsToReopen { 2264 // Close the pull in the database 2265 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2266 if err != nil { 2267 log.Println("failed to close pull", err) 2268 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2314 Body: body, 2315 TargetBranch: targetBranch, 2316 OwnerDid: user.Did, 2317 + RepoAt: f.RepoAt(), 2318 Rkey: rkey, 2319 Submissions: []*db.PullSubmission{ 2320 &initialSubmission,
+31 -13
appview/repo/artifact.go
··· 1 package repo 2 3 import ( 4 "fmt" 5 "log" 6 "net/http" ··· 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "github.com/dustin/go-humanize" 13 "github.com/go-chi/chi/v5" 14 "github.com/go-git/go-git/v5/plumbing" ··· 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/knotclient" 21 "tangled.sh/tangled.sh/core/tid" 22 "tangled.sh/tangled.sh/core/types" 23 ) ··· 33 return 34 } 35 36 - tag, err := rp.resolveTag(f, tagParam) 37 if err != nil { 38 log.Println("failed to resolve tag", err) 39 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 76 Artifact: uploadBlobResp.Blob, 77 CreatedAt: createdAt.Format(time.RFC3339), 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 80 Tag: tag.Tag.Hash[:], 81 }, 82 }, ··· 100 artifact := db.Artifact{ 101 Did: user.Did, 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 104 Tag: tag.Tag.Hash, 105 CreatedAt: createdAt, 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 140 return 141 } 142 143 - tag, err := rp.resolveTag(f, tagParam) 144 if err != nil { 145 log.Println("failed to resolve tag", err) 146 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 155 156 artifacts, err := db.GetArtifact( 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 db.FilterEq("name", filename), 161 ) ··· 197 198 artifacts, err := db.GetArtifact( 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 201 db.FilterEq("tag", tag[:]), 202 db.FilterEq("name", filename), 203 ) ··· 239 defer tx.Rollback() 240 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 243 db.FilterEq("tag", artifact.Tag[:]), 244 db.FilterEq("name", filename), 245 ) ··· 259 w.Write([]byte{}) 260 } 261 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 tagParam, err := url.QueryUnescape(tagParam) 264 if err != nil { 265 return nil, err 266 } 267 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 - if err != nil { 270 - return nil, err 271 } 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err 277 } 278
··· 1 package repo 2 3 import ( 4 + "context" 5 + "encoding/json" 6 "fmt" 7 "log" 8 "net/http" ··· 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 "github.com/dustin/go-humanize" 16 "github.com/go-chi/chi/v5" 17 "github.com/go-git/go-git/v5/plumbing" ··· 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 "tangled.sh/tangled.sh/core/tid" 25 "tangled.sh/tangled.sh/core/types" 26 ) ··· 36 return 37 } 38 39 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 40 if err != nil { 41 log.Println("failed to resolve tag", err) 42 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 79 Artifact: uploadBlobResp.Blob, 80 CreatedAt: createdAt.Format(time.RFC3339), 81 Name: handler.Filename, 82 + Repo: f.RepoAt().String(), 83 Tag: tag.Tag.Hash[:], 84 }, 85 }, ··· 103 artifact := db.Artifact{ 104 Did: user.Did, 105 Rkey: rkey, 106 + RepoAt: f.RepoAt(), 107 Tag: tag.Tag.Hash, 108 CreatedAt: createdAt, 109 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 143 return 144 } 145 146 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 if err != nil { 148 log.Println("failed to resolve tag", err) 149 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 158 159 artifacts, err := db.GetArtifact( 160 rp.db, 161 + db.FilterEq("repo_at", f.RepoAt()), 162 db.FilterEq("tag", tag.Tag.Hash[:]), 163 db.FilterEq("name", filename), 164 ) ··· 200 201 artifacts, err := db.GetArtifact( 202 rp.db, 203 + db.FilterEq("repo_at", f.RepoAt()), 204 db.FilterEq("tag", tag[:]), 205 db.FilterEq("name", filename), 206 ) ··· 242 defer tx.Rollback() 243 244 err = db.DeleteArtifact(tx, 245 + db.FilterEq("repo_at", f.RepoAt()), 246 db.FilterEq("tag", artifact.Tag[:]), 247 db.FilterEq("name", filename), 248 ) ··· 262 w.Write([]byte{}) 263 } 264 265 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 266 tagParam, err := url.QueryUnescape(tagParam) 267 if err != nil { 268 return nil, err 269 } 270 271 + scheme := "http" 272 + if !rp.config.Core.Dev { 273 + scheme = "https" 274 + } 275 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 276 + xrpcc := &indigoxrpc.Client{ 277 + Host: host, 278 } 279 280 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 281 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 282 if err != nil { 283 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 284 + log.Println("failed to call XRPC repo.tags", xrpcerr) 285 + return nil, xrpcerr 286 + } 287 log.Println("failed to reach knotserver", err) 288 + return nil, err 289 + } 290 + 291 + var result types.RepoTagsResponse 292 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 293 + log.Println("failed to decode XRPC tags response", err) 294 return nil, err 295 } 296
+170
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/pagination" 13 + "tangled.sh/tangled.sh/core/appview/reporesolver" 14 + 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/gorilla/feeds" 17 + ) 18 + 19 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 20 + const feedLimitPerType = 100 21 + 22 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 32 + if err != nil { 33 + return nil, err 34 + } 35 + 36 + feed := &feeds.Feed{ 37 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 38 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 39 + Items: make([]*feeds.Item, 0), 40 + Updated: time.UnixMilli(0), 41 + } 42 + 43 + for _, pull := range pulls { 44 + items, err := rp.createPullItems(ctx, pull, f) 45 + if err != nil { 46 + return nil, err 47 + } 48 + feed.Items = append(feed.Items, items...) 49 + } 50 + 51 + for _, issue := range issues { 52 + item, err := rp.createIssueItem(ctx, issue, f) 53 + if err != nil { 54 + return nil, err 55 + } 56 + feed.Items = append(feed.Items, item) 57 + } 58 + 59 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 60 + if a.Created.After(b.Created) { 61 + return -1 62 + } 63 + return 1 64 + }) 65 + 66 + if len(feed.Items) > 0 { 67 + feed.Updated = feed.Items[0].Created 68 + } 69 + 70 + return feed, nil 71 + } 72 + 73 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 75 + if err != nil { 76 + return nil, err 77 + } 78 + 79 + var items []*feeds.Item 80 + 81 + state := rp.getPullState(pull) 82 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 83 + 84 + mainItem := &feeds.Item{ 85 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 86 + Description: description, 87 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 88 + Created: pull.Created, 89 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 90 + } 91 + items = append(items, mainItem) 92 + 93 + for _, round := range pull.Submissions { 94 + if round == nil || round.RoundNumber == 0 { 95 + continue 96 + } 97 + 98 + roundItem := &feeds.Item{ 99 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 100 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 101 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 102 + Created: round.Created, 103 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 104 + } 105 + items = append(items, roundItem) 106 + } 107 + 108 + return items, nil 109 + } 110 + 111 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 + if err != nil { 114 + return nil, err 115 + } 116 + 117 + state := "closed" 118 + if issue.Open { 119 + state = "opened" 120 + } 121 + 122 + return &feeds.Item{ 123 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 124 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 125 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 126 + Created: issue.Created, 127 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 128 + }, nil 129 + } 130 + 131 + func (rp *Repo) getPullState(pull *db.Pull) string { 132 + if pull.State == db.PullOpen { 133 + return "opened" 134 + } 135 + return pull.State.String() 136 + } 137 + 138 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 140 + 141 + if pull.State == db.PullMerged { 142 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 143 + } 144 + 145 + return fmt.Sprintf("%s in %s", base, repoName) 146 + } 147 + 148 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + f, err := rp.repoResolver.Resolve(r) 150 + if err != nil { 151 + log.Println("failed to fully resolve repo:", err) 152 + return 153 + } 154 + 155 + feed, err := rp.getRepoFeed(r.Context(), f) 156 + if err != nil { 157 + log.Println("failed to get repo feed:", err) 158 + rp.pages.Error500(w) 159 + return 160 + } 161 + 162 + atom, err := feed.ToAtom() 163 + if err != nil { 164 + rp.pages.Error500(w) 165 + return 166 + } 167 + 168 + w.Header().Set("content-type", "application/atom+xml") 169 + w.Write([]byte(atom)) 170 + }
+198 -101
appview/repo/index.go
··· 1 package repo 2 3 import ( 4 - "encoding/json" 5 "fmt" 6 "log" 7 "net/http" 8 "slices" 9 "sort" 10 "strings" 11 12 "tangled.sh/tangled.sh/core/appview/commitverify" 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 - "tangled.sh/tangled.sh/core/knotclient" 19 "tangled.sh/tangled.sh/core/types" 20 21 "github.com/go-chi/chi/v5" ··· 24 25 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 ref := chi.URLParam(r, "ref") 27 f, err := rp.repoResolver.Resolve(r) 28 if err != nil { 29 log.Println("failed to fully resolve repo", err) 30 return 31 } 32 33 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 34 - if err != nil { 35 - log.Printf("failed to create unsigned client for %s", f.Knot) 36 - rp.pages.Error503(w) 37 - return 38 } 39 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 - if err != nil { 42 rp.pages.Error503(w) 43 - log.Println("failed to reach knotserver", err) 44 return 45 } 46 ··· 101 log.Println(err) 102 } 103 104 - user := rp.oauth.GetUser(r) 105 - repoInfo := f.RepoInfo(user) 106 - 107 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 108 - if err != nil { 109 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 110 - rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 111 - } 112 - 113 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 114 - if err != nil { 115 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 116 - return 117 - } 118 - 119 - var forkInfo *types.ForkInfo 120 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 121 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 - if err != nil { 123 - log.Printf("Failed to fetch fork information: %v", err) 124 - return 125 - } 126 - } 127 - 128 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 130 if err != nil { 131 log.Printf("failed to compute language percentages: %s", err) 132 // non-fatal ··· 143 } 144 145 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 146 - LoggedInUser: user, 147 - RepoInfo: repoInfo, 148 - TagMap: tagMap, 149 - RepoIndexResponse: *result, 150 - CommitsTrunc: commitsTrunc, 151 - TagsTrunc: tagsTrunc, 152 - ForkInfo: forkInfo, 153 BranchesTrunc: branchesTrunc, 154 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 155 VerifiedCommits: vc, ··· 159 } 160 161 func (rp *Repo) getLanguageInfo( 162 f *reporesolver.ResolvedRepo, 163 - signedClient *knotclient.SignedClient, 164 isDefaultRef bool, 165 ) ([]types.RepoLanguageDetails, error) { 166 // first attempt to fetch from db 167 langs, err := db.GetRepoLanguages( 168 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 171 ) 172 173 if err != nil || langs == nil { 174 - // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 176 if err != nil { 177 return nil, err 178 } 179 - if ls == nil { 180 return nil, nil 181 } 182 183 - for l, s := range ls.Languages { 184 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 187 IsDefaultRef: isDefaultRef, 188 - Language: l, 189 - Bytes: s, 190 }) 191 } 192 ··· 230 return languageStats, nil 231 } 232 233 - func getForkInfo( 234 - repoInfo repoinfo.RepoInfo, 235 - rp *Repo, 236 - f *reporesolver.ResolvedRepo, 237 - user *oauth.User, 238 - signedClient *knotclient.SignedClient, 239 - ) (*types.ForkInfo, error) { 240 - if user == nil { 241 - return nil, nil 242 } 243 244 - forkInfo := types.ForkInfo{ 245 - IsFork: repoInfo.Source != nil, 246 - Status: types.UpToDate, 247 } 248 249 - if !forkInfo.IsFork { 250 - forkInfo.IsFork = false 251 - return &forkInfo, nil 252 } 253 254 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 255 - if err != nil { 256 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 257 - return nil, err 258 } 259 260 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 261 - if err != nil { 262 - log.Println("failed to reach knotserver", err) 263 - return nil, err 264 - } 265 266 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 268 - }) { 269 - forkInfo.Status = types.MissingBranch 270 - return &forkInfo, nil 271 - } 272 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 274 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 - log.Printf("failed to update tracking branch: %s", err) 276 - return nil, err 277 - } 278 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 281 - var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 283 - if err != nil { 284 - log.Printf("failed to check if fork is ahead/behind: %s", err) 285 - return nil, err 286 } 287 288 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 289 - log.Printf("failed to decode fork status: %s", err) 290 - return nil, err 291 } 292 293 - forkInfo.Status = status.Status 294 - return &forkInfo, nil 295 }
··· 1 package repo 2 3 import ( 4 + "errors" 5 "fmt" 6 "log" 7 "net/http" 8 + "net/url" 9 "slices" 10 "sort" 11 "strings" 12 + "sync" 13 + "time" 14 15 + "context" 16 + "encoding/json" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-git/go-git/v5/plumbing" 20 + "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/commitverify" 22 "tangled.sh/tangled.sh/core/appview/db" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 "tangled.sh/tangled.sh/core/types" 28 29 "github.com/go-chi/chi/v5" ··· 32 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 ref := chi.URLParam(r, "ref") 35 + ref, _ = url.PathUnescape(ref) 36 + 37 f, err := rp.repoResolver.Resolve(r) 38 if err != nil { 39 log.Println("failed to fully resolve repo", err) 40 return 41 } 42 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 } 51 52 + user := rp.oauth.GetUser(r) 53 + repoInfo := f.RepoInfo(user) 54 + 55 + // Build index response from multiple XRPC calls 56 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 + log.Println("failed to call XRPC repo.index", err) 60 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 + LoggedInUser: user, 62 + NeedsKnotUpgrade: true, 63 + RepoInfo: repoInfo, 64 + }) 65 + return 66 + } 67 + 68 rp.pages.Error503(w) 69 + log.Println("failed to build index response", err) 70 return 71 } 72 ··· 127 log.Println(err) 128 } 129 130 // TODO: a bit dirty 131 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 132 if err != nil { 133 log.Printf("failed to compute language percentages: %s", err) 134 // non-fatal ··· 145 } 146 147 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 148 + LoggedInUser: user, 149 + RepoInfo: repoInfo, 150 + TagMap: tagMap, 151 + RepoIndexResponse: *result, 152 + CommitsTrunc: commitsTrunc, 153 + TagsTrunc: tagsTrunc, 154 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 155 BranchesTrunc: branchesTrunc, 156 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 157 VerifiedCommits: vc, ··· 161 } 162 163 func (rp *Repo) getLanguageInfo( 164 + ctx context.Context, 165 f *reporesolver.ResolvedRepo, 166 + xrpcc *indigoxrpc.Client, 167 + currentRef string, 168 isDefaultRef bool, 169 ) ([]types.RepoLanguageDetails, error) { 170 // first attempt to fetch from db 171 langs, err := db.GetRepoLanguages( 172 rp.db, 173 + db.FilterEq("repo_at", f.RepoAt()), 174 + db.FilterEq("ref", currentRef), 175 ) 176 177 if err != nil || langs == nil { 178 + // non-fatal, fetch langs from ks via XRPC 179 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 180 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 if err != nil { 182 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 + log.Println("failed to call XRPC repo.languages", xrpcerr) 184 + return nil, xrpcerr 185 + } 186 return nil, err 187 } 188 + 189 + if ls == nil || ls.Languages == nil { 190 return nil, nil 191 } 192 193 + for _, lang := range ls.Languages { 194 langs = append(langs, db.RepoLanguage{ 195 + RepoAt: f.RepoAt(), 196 + Ref: currentRef, 197 IsDefaultRef: isDefaultRef, 198 + Language: lang.Name, 199 + Bytes: lang.Size, 200 }) 201 } 202 ··· 240 return languageStats, nil 241 } 242 243 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 244 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 245 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 246 + 247 + // first get branches to determine the ref if not specified 248 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 249 + if err != nil { 250 + return nil, fmt.Errorf("failed to call repoBranches: %w", err) 251 } 252 253 + var branchesResp types.RepoBranchesResponse 254 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 255 + return nil, fmt.Errorf("failed to unmarshal branches response: %w", err) 256 } 257 258 + // if no ref specified, use default branch or first available 259 + if ref == "" { 260 + for _, branch := range branchesResp.Branches { 261 + if branch.IsDefault { 262 + ref = branch.Name 263 + break 264 + } 265 + } 266 } 267 268 + // if ref is still empty, this means the default branch is not set 269 + if ref == "" { 270 + return &types.RepoIndexResponse{ 271 + IsEmpty: true, 272 + Branches: branchesResp.Branches, 273 + }, nil 274 } 275 276 + // now run the remaining queries in parallel 277 + var wg sync.WaitGroup 278 + var errs error 279 + 280 + var ( 281 + tagsResp types.RepoTagsResponse 282 + treeResp *tangled.RepoTree_Output 283 + logResp types.RepoLogResponse 284 + readmeContent string 285 + readmeFileName string 286 + ) 287 + 288 + // tags 289 + wg.Add(1) 290 + go func() { 291 + defer wg.Done() 292 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 293 + if err != nil { 294 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 295 + return 296 + } 297 + 298 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 299 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 300 + } 301 + }() 302 + 303 + // tree/files 304 + wg.Add(1) 305 + go func() { 306 + defer wg.Done() 307 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 308 + if err != nil { 309 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 310 + return 311 + } 312 + treeResp = resp 313 + }() 314 + 315 + // commits 316 + wg.Add(1) 317 + go func() { 318 + defer wg.Done() 319 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 320 + if err != nil { 321 + errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 322 + return 323 + } 324 + 325 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 326 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 327 + } 328 + }() 329 + 330 + // readme content 331 + wg.Add(1) 332 + go func() { 333 + defer wg.Done() 334 + for _, filename := range markup.ReadmeFilenames { 335 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 336 + if err != nil { 337 + continue 338 + } 339 340 + if blobResp == nil { 341 + continue 342 + } 343 344 + readmeContent = blobResp.Content 345 + readmeFileName = filename 346 + break 347 + } 348 + }() 349 350 + wg.Wait() 351 352 + if errs != nil { 353 + return nil, errs 354 } 355 356 + var files []types.NiceTree 357 + if treeResp != nil && treeResp.Files != nil { 358 + for _, file := range treeResp.Files { 359 + niceFile := types.NiceTree{ 360 + IsFile: file.Is_file, 361 + IsSubtree: file.Is_subtree, 362 + Name: file.Name, 363 + Mode: file.Mode, 364 + Size: file.Size, 365 + } 366 + if file.Last_commit != nil { 367 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 368 + niceFile.LastCommit = &types.LastCommitInfo{ 369 + Hash: plumbing.NewHash(file.Last_commit.Hash), 370 + Message: file.Last_commit.Message, 371 + When: when, 372 + } 373 + } 374 + files = append(files, niceFile) 375 + } 376 } 377 378 + result := &types.RepoIndexResponse{ 379 + IsEmpty: false, 380 + Ref: ref, 381 + Readme: readmeContent, 382 + ReadmeFileName: readmeFileName, 383 + Commits: logResp.Commits, 384 + Description: logResp.Description, 385 + Files: files, 386 + Branches: branchesResp.Branches, 387 + Tags: tagsResp.Tags, 388 + TotalCommits: logResp.Total, 389 + } 390 + 391 + return result, nil 392 }
+686 -369
appview/repo/repo.go
··· 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/commitverify" 22 "tangled.sh/tangled.sh/core/appview/config" ··· 26 "tangled.sh/tangled.sh/core/appview/pages" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 "tangled.sh/tangled.sh/core/idresolver" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 "tangled.sh/tangled.sh/core/patchutil" 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/tid" 35 "tangled.sh/tangled.sh/core/types" 36 37 securejoin "github.com/cyphar/filepath-securejoin" 38 "github.com/go-chi/chi/v5" 39 "github.com/go-git/go-git/v5/plumbing" 40 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 - lexutil "github.com/bluesky-social/indigo/lex/util" 43 ) 44 45 type Repo struct { ··· 53 enforcer *rbac.Enforcer 54 notifier notify.Notifier 55 logger *slog.Logger 56 } 57 58 func New( ··· 80 } 81 } 82 83 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 84 f, err := rp.repoResolver.Resolve(r) 85 if err != nil { ··· 96 } 97 98 ref := chi.URLParam(r, "ref") 99 100 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 101 - if err != nil { 102 - log.Println("failed to create unsigned client", err) 103 return 104 } 105 106 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 107 - if err != nil { 108 - log.Println("failed to reach knotserver", err) 109 return 110 } 111 112 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 113 - if err != nil { 114 - log.Println("failed to reach knotserver", err) 115 return 116 } 117 118 tagMap := make(map[string][]string) 119 - for _, tag := range tagResult.Tags { 120 - hash := tag.Hash 121 - if tag.Tag != nil { 122 - hash = tag.Tag.Target.String() 123 } 124 - tagMap[hash] = append(tagMap[hash], tag.Name) 125 } 126 127 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 128 - if err != nil { 129 - log.Println("failed to reach knotserver", err) 130 return 131 } 132 133 - for _, branch := range branchResult.Branches { 134 - hash := branch.Hash 135 - tagMap[hash] = append(tagMap[hash], branch.Name) 136 } 137 138 user := rp.oauth.GetUser(r) 139 140 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 141 if err != nil { 142 log.Println("failed to fetch email to did mapping", err) 143 } 144 145 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 146 if err != nil { 147 log.Println(err) 148 } ··· 150 repoInfo := f.RepoInfo(user) 151 152 var shas []string 153 - for _, c := range repolog.Commits { 154 shas = append(shas, c.Hash.String()) 155 } 156 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 163 LoggedInUser: user, 164 TagMap: tagMap, 165 RepoInfo: repoInfo, 166 - RepoLogResponse: *repolog, 167 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 168 VerifiedCommits: vc, 169 Pipelines: pipelines, ··· 192 return 193 } 194 195 - repoAt := f.RepoAt 196 rkey := repoAt.RecordKey().String() 197 if rkey == "" { 198 log.Println("invalid aturi for repo", err) ··· 242 Record: &lexutil.LexiconTypeDecoder{ 243 Val: &tangled.Repo{ 244 Knot: f.Knot, 245 - Name: f.RepoName, 246 Owner: user.Did, 247 - CreatedAt: f.CreatedAt, 248 Description: &newDescription, 249 Spindle: &f.Spindle, 250 }, ··· 275 return 276 } 277 ref := chi.URLParam(r, "ref") 278 - protocol := "http" 279 - if !rp.config.Core.Dev { 280 - protocol = "https" 281 - } 282 283 var diffOpts types.DiffOpts 284 if d := r.URL.Query().Get("diff"); d == "split" { ··· 290 return 291 } 292 293 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 294 - if err != nil { 295 - log.Println("failed to reach knotserver", err) 296 - return 297 } 298 299 - body, err := io.ReadAll(resp.Body) 300 - if err != nil { 301 - log.Printf("Error reading response body: %v", err) 302 return 303 } 304 305 var result types.RepoCommitResponse 306 - err = json.Unmarshal(body, &result) 307 - if err != nil { 308 - log.Println("failed to parse response:", err) 309 return 310 } 311 ··· 350 } 351 352 ref := chi.URLParam(r, "ref") 353 treePath := chi.URLParam(r, "*") 354 - protocol := "http" 355 if !rp.config.Core.Dev { 356 - protocol = "https" 357 } 358 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 359 - if err != nil { 360 - log.Println("failed to reach knotserver", err) 361 - return 362 } 363 364 - body, err := io.ReadAll(resp.Body) 365 - if err != nil { 366 - log.Printf("Error reading response body: %v", err) 367 return 368 } 369 370 - var result types.RepoTreeResponse 371 - err = json.Unmarshal(body, &result) 372 - if err != nil { 373 - log.Println("failed to parse response:", err) 374 - return 375 } 376 377 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 378 // so we can safely redirect to the "parent" (which is the same file). 379 - unescapedTreePath, _ := url.PathUnescape(treePath) 380 - if len(result.Files) == 0 && result.Parent == unescapedTreePath { 381 - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 382 return 383 } 384 385 user := rp.oauth.GetUser(r) 386 387 var breadcrumbs [][]string 388 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 389 if treePath != "" { 390 for idx, elem := range strings.Split(treePath, "/") { 391 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 392 } 393 } 394 ··· 410 return 411 } 412 413 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 414 - if err != nil { 415 - log.Println("failed to create unsigned client", err) 416 return 417 } 418 419 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 420 - if err != nil { 421 - log.Println("failed to reach knotserver", err) 422 return 423 } 424 425 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 426 if err != nil { 427 log.Println("failed grab artifacts", err) 428 return ··· 454 rp.pages.RepoTags(w, pages.RepoTagsParams{ 455 LoggedInUser: user, 456 RepoInfo: f.RepoInfo(user), 457 - RepoTagsResponse: *result, 458 ArtifactMap: artifactMap, 459 DanglingArtifacts: danglingArtifacts, 460 }) ··· 467 return 468 } 469 470 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 471 - if err != nil { 472 - log.Println("failed to create unsigned client", err) 473 return 474 } 475 476 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 477 - if err != nil { 478 - log.Println("failed to reach knotserver", err) 479 return 480 } 481 ··· 485 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 486 LoggedInUser: user, 487 RepoInfo: f.RepoInfo(user), 488 - RepoBranchesResponse: *result, 489 }) 490 } 491 ··· 497 } 498 499 ref := chi.URLParam(r, "ref") 500 filePath := chi.URLParam(r, "*") 501 - protocol := "http" 502 if !rp.config.Core.Dev { 503 - protocol = "https" 504 } 505 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 506 - if err != nil { 507 - log.Println("failed to reach knotserver", err) 508 - return 509 } 510 511 - body, err := io.ReadAll(resp.Body) 512 - if err != nil { 513 - log.Printf("Error reading response body: %v", err) 514 return 515 } 516 517 - var result types.RepoBlobResponse 518 - err = json.Unmarshal(body, &result) 519 - if err != nil { 520 - log.Println("failed to parse response:", err) 521 - return 522 - } 523 524 var breadcrumbs [][]string 525 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 526 if filePath != "" { 527 for idx, elem := range strings.Split(filePath, "/") { 528 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 529 } 530 } 531 532 showRendered := false 533 renderToggle := false 534 535 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 536 renderToggle = true 537 showRendered = r.URL.Query().Get("code") != "true" 538 } ··· 542 var isVideo bool 543 var contentSrc string 544 545 - if result.IsBinary { 546 - ext := strings.ToLower(filepath.Ext(result.Path)) 547 switch ext { 548 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 549 isImage = true ··· 553 unsupported = true 554 } 555 556 - // fetch the actual binary content like in RepoBlobRaw 557 558 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 559 contentSrc = blobURL 560 if !rp.config.Core.Dev { 561 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 562 } 563 } 564 565 user := rp.oauth.GetUser(r) 566 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 567 - LoggedInUser: user, 568 - RepoInfo: f.RepoInfo(user), 569 - RepoBlobResponse: result, 570 - BreadCrumbs: breadcrumbs, 571 - ShowRendered: showRendered, 572 - RenderToggle: renderToggle, 573 - Unsupported: unsupported, 574 - IsImage: isImage, 575 - IsVideo: isVideo, 576 - ContentSrc: contentSrc, 577 }) 578 } 579 ··· 586 } 587 588 ref := chi.URLParam(r, "ref") 589 filePath := chi.URLParam(r, "*") 590 591 - protocol := "http" 592 if !rp.config.Core.Dev { 593 - protocol = "https" 594 } 595 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 596 - resp, err := http.Get(blobURL) 597 if err != nil { 598 - log.Println("failed to reach knotserver:", err) 599 rp.pages.Error503(w) 600 return 601 } 602 defer resp.Body.Close() 603 604 if resp.StatusCode != http.StatusOK { 605 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 606 w.WriteHeader(resp.StatusCode) ··· 616 return 617 } 618 619 - if strings.Contains(contentType, "text/plain") { 620 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 621 w.Write(body) 622 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 623 w.Header().Set("Content-Type", contentType) 624 w.Write(body) 625 } else { ··· 629 } 630 } 631 632 // modify the spindle configured for this repo 633 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 634 user := rp.oauth.GetUser(r) ··· 648 return 649 } 650 651 - repoAt := f.RepoAt 652 rkey := repoAt.RecordKey().String() 653 if rkey == "" { 654 fail("Failed to resolve repo. Try again later", err) ··· 656 } 657 658 newSpindle := r.FormValue("spindle") 659 client, err := rp.oauth.AuthorizedClient(r) 660 if err != nil { 661 fail("Failed to authorize. Try again later.", err) 662 return 663 } 664 665 - // ensure that this is a valid spindle for this user 666 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 667 - if err != nil { 668 - fail("Failed to find spindles. Try again later.", err) 669 - return 670 } 671 672 - if !slices.Contains(validSpindles, newSpindle) { 673 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 674 - return 675 } 676 677 // optimistic update 678 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 679 if err != nil { 680 fail("Failed to update spindle. Try again later.", err) 681 return ··· 694 Record: &lexutil.LexiconTypeDecoder{ 695 Val: &tangled.Repo{ 696 Knot: f.Knot, 697 - Name: f.RepoName, 698 Owner: user.Did, 699 - CreatedAt: f.CreatedAt, 700 Description: &f.Description, 701 - Spindle: &newSpindle, 702 }, 703 }, 704 }) ··· 708 return 709 } 710 711 - // add this spindle to spindle stream 712 - rp.spindlestream.AddSource( 713 - context.Background(), 714 - eventconsumer.NewSpindleSource(newSpindle), 715 - ) 716 717 rp.pages.HxRefresh(w) 718 } ··· 740 fail("Invalid form.", nil) 741 return 742 } 743 744 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 745 if err != nil { ··· 751 fail("You seem to be adding yourself as a collaborator.", nil) 752 return 753 } 754 - 755 l = l.With("collaborator", collaboratorIdent.Handle) 756 l = l.With("knot", f.Knot) 757 - l.Info("adding to knot") 758 759 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 760 if err != nil { 761 - fail("Failed to add to knot.", err) 762 return 763 } 764 765 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 766 if err != nil { 767 - fail("Failed to add to knot.", err) 768 return 769 } 770 771 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 772 - if err != nil { 773 - fail("Knot was unreachable.", err) 774 - return 775 - } 776 - 777 - if ksResp.StatusCode != http.StatusNoContent { 778 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 779 - return 780 - } 781 782 tx, err := rp.db.BeginTx(r.Context(), nil) 783 if err != nil { 784 fail("Failed to add collaborator.", err) 785 return 786 } 787 - defer func() { 788 - tx.Rollback() 789 - err = rp.enforcer.E.LoadPolicy() 790 - if err != nil { 791 - fail("Failed to add collaborator.", err) 792 } 793 - }() 794 795 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 796 if err != nil { ··· 798 return 799 } 800 801 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 802 if err != nil { 803 fail("Failed to add collaborator.", err) 804 return ··· 816 return 817 } 818 819 rp.pages.HxRefresh(w) 820 } 821 822 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 823 user := rp.oauth.GetUser(r) 824 825 f, err := rp.repoResolver.Resolve(r) 826 if err != nil { 827 log.Println("failed to get repo and knot", err) ··· 834 log.Println("failed to get authorized client", err) 835 return 836 } 837 - repoRkey := f.RepoAt.RecordKey().String() 838 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 839 Collection: tangled.RepoNSID, 840 Repo: user.Did, 841 - Rkey: repoRkey, 842 }) 843 if err != nil { 844 log.Printf("failed to delete record: %s", err) 845 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 846 - return 847 - } 848 - log.Println("removed repo record ", f.RepoAt.String()) 849 - 850 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 851 - if err != nil { 852 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 853 return 854 } 855 856 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 857 if err != nil { 858 - log.Println("failed to create client to ", f.Knot) 859 return 860 } 861 862 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 863 - if err != nil { 864 - log.Printf("failed to make request to %s: %s", f.Knot, err) 865 return 866 } 867 - 868 - if ksResp.StatusCode != http.StatusNoContent { 869 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 870 - } else { 871 - log.Println("removed repo from knot ", f.Knot) 872 - } 873 874 tx, err := rp.db.BeginTx(r.Context(), nil) 875 if err != nil { ··· 888 // remove collaborator RBAC 889 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 890 if err != nil { 891 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 892 return 893 } 894 for _, c := range repoCollaborators { ··· 900 // remove repo RBAC 901 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 902 if err != nil { 903 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 904 return 905 } 906 907 // remove repo from db 908 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 909 if err != nil { 910 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 911 return 912 } 913 log.Println("removed repo from db") ··· 936 return 937 } 938 939 branch := r.FormValue("branch") 940 if branch == "" { 941 http.Error(w, "malformed form", http.StatusBadRequest) 942 return 943 } 944 945 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 946 - if err != nil { 947 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 948 - return 949 - } 950 - 951 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 952 - if err != nil { 953 - log.Println("failed to create client to ", f.Knot) 954 - return 955 - } 956 - 957 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 958 if err != nil { 959 - log.Printf("failed to make request to %s: %s", f.Knot, err) 960 return 961 } 962 963 - if ksResp.StatusCode != http.StatusNoContent { 964 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 965 return 966 } 967 968 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 969 } 970 971 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 994 r, 995 oauth.WithService(f.Spindle), 996 oauth.WithLxm(lxm), 997 oauth.WithDev(rp.config.Core.Dev), 998 ) 999 if err != nil { ··· 1021 r.Context(), 1022 spindleClient, 1023 &tangled.RepoAddSecret_Input{ 1024 - Repo: f.RepoAt.String(), 1025 Key: key, 1026 Value: value, 1027 }, ··· 1039 r.Context(), 1040 spindleClient, 1041 &tangled.RepoRemoveSecret_Input{ 1042 - Repo: f.RepoAt.String(), 1043 Key: key, 1044 }, 1045 ) ··· 1080 case "pipelines": 1081 rp.pipelineSettings(w, r) 1082 } 1083 - 1084 - // user := rp.oauth.GetUser(r) 1085 - // repoCollaborators, err := f.Collaborators(r.Context()) 1086 - // if err != nil { 1087 - // log.Println("failed to get collaborators", err) 1088 - // } 1089 - 1090 - // isCollaboratorInviteAllowed := false 1091 - // if user != nil { 1092 - // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1093 - // if err == nil && ok { 1094 - // isCollaboratorInviteAllowed = true 1095 - // } 1096 - // } 1097 - 1098 - // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1099 - // if err != nil { 1100 - // log.Println("failed to create unsigned client", err) 1101 - // return 1102 - // } 1103 - 1104 - // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1105 - // if err != nil { 1106 - // log.Println("failed to reach knotserver", err) 1107 - // return 1108 - // } 1109 - 1110 - // // all spindles that this user is a member of 1111 - // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1112 - // if err != nil { 1113 - // log.Println("failed to fetch spindles", err) 1114 - // return 1115 - // } 1116 - 1117 - // var secrets []*tangled.RepoListSecrets_Secret 1118 - // if f.Spindle != "" { 1119 - // if spindleClient, err := rp.oauth.ServiceClient( 1120 - // r, 1121 - // oauth.WithService(f.Spindle), 1122 - // oauth.WithLxm(tangled.RepoListSecretsNSID), 1123 - // oauth.WithDev(rp.config.Core.Dev), 1124 - // ); err != nil { 1125 - // log.Println("failed to create spindle client", err) 1126 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1127 - // log.Println("failed to fetch secrets", err) 1128 - // } else { 1129 - // secrets = resp.Secrets 1130 - // } 1131 - // } 1132 - 1133 - // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1134 - // LoggedInUser: user, 1135 - // RepoInfo: f.RepoInfo(user), 1136 - // Collaborators: repoCollaborators, 1137 - // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1138 - // Branches: result.Branches, 1139 - // Spindles: spindles, 1140 - // CurrentSpindle: f.Spindle, 1141 - // Secrets: secrets, 1142 - // }) 1143 } 1144 1145 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1146 f, err := rp.repoResolver.Resolve(r) 1147 user := rp.oauth.GetUser(r) 1148 1149 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1150 - if err != nil { 1151 - log.Println("failed to create unsigned client", err) 1152 return 1153 } 1154 1155 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1156 - if err != nil { 1157 - log.Println("failed to reach knotserver", err) 1158 return 1159 } 1160 ··· 1189 f, err := rp.repoResolver.Resolve(r) 1190 user := rp.oauth.GetUser(r) 1191 1192 - // all spindles that this user is a member of 1193 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1194 if err != nil { 1195 log.Println("failed to fetch spindles", err) 1196 return ··· 1202 r, 1203 oauth.WithService(f.Spindle), 1204 oauth.WithLxm(tangled.RepoListSecretsNSID), 1205 oauth.WithDev(rp.config.Core.Dev), 1206 ); err != nil { 1207 log.Println("failed to create spindle client", err) 1208 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1209 log.Println("failed to fetch secrets", err) 1210 } else { 1211 secrets = resp.Secrets ··· 1246 } 1247 1248 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1249 user := rp.oauth.GetUser(r) 1250 f, err := rp.repoResolver.Resolve(r) 1251 if err != nil { ··· 1255 1256 switch r.Method { 1257 case http.MethodPost: 1258 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1259 if err != nil { 1260 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1261 return 1262 } 1263 1264 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1265 - if err != nil { 1266 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1267 return 1268 } 1269 1270 - var uri string 1271 - if rp.config.Core.Dev { 1272 - uri = "http" 1273 - } else { 1274 - uri = "https" 1275 - } 1276 - forkName := fmt.Sprintf("%s", f.RepoName) 1277 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1278 - 1279 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1280 - if err != nil { 1281 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1282 return 1283 } 1284 ··· 1311 }) 1312 1313 case http.MethodPost: 1314 1315 - knot := r.FormValue("knot") 1316 - if knot == "" { 1317 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1318 return 1319 } 1320 1321 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1322 if err != nil || !ok { 1323 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1324 return 1325 } 1326 1327 - forkName := fmt.Sprintf("%s", f.RepoName) 1328 - 1329 // this check is *only* to see if the forked repo name already exists 1330 // in the user's account. 1331 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1332 if err != nil { 1333 if errors.Is(err, sql.ErrNoRows) { 1334 // no existing repo with this name found, we can use the name as is ··· 1341 // repo with this name already exists, append random string 1342 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1343 } 1344 - secret, err := db.GetRegistrationKey(rp.db, knot) 1345 - if err != nil { 1346 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1347 - return 1348 - } 1349 - 1350 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1351 - if err != nil { 1352 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1353 - return 1354 - } 1355 1356 - var uri string 1357 if rp.config.Core.Dev { 1358 uri = "http" 1359 - } else { 1360 - uri = "https" 1361 } 1362 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1363 - sourceAt := f.RepoAt.String() 1364 1365 rkey := tid.TID() 1366 repo := &db.Repo{ 1367 Did: user.Did, 1368 Name: forkName, 1369 - Knot: knot, 1370 Rkey: rkey, 1371 Source: sourceAt, 1372 } 1373 1374 - tx, err := rp.db.BeginTx(r.Context(), nil) 1375 - if err != nil { 1376 - log.Println(err) 1377 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1378 - return 1379 - } 1380 - defer func() { 1381 - tx.Rollback() 1382 - err = rp.enforcer.E.LoadPolicy() 1383 - if err != nil { 1384 - log.Println("failed to rollback policies") 1385 - } 1386 - }() 1387 - 1388 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1389 - if err != nil { 1390 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1391 - return 1392 - } 1393 - 1394 - switch resp.StatusCode { 1395 - case http.StatusConflict: 1396 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1397 - return 1398 - case http.StatusInternalServerError: 1399 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1400 - case http.StatusNoContent: 1401 - // continue 1402 - } 1403 - 1404 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1405 if err != nil { 1406 - log.Println("failed to get authorized client", err) 1407 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1408 return 1409 } 1410 ··· 1423 }}, 1424 }) 1425 if err != nil { 1426 - log.Printf("failed to create record: %s", err) 1427 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1428 return 1429 } 1430 - log.Println("created repo record: ", atresp.Uri) 1431 1432 - repo.AtUri = atresp.Uri 1433 err = db.AddRepo(tx, repo) 1434 if err != nil { 1435 log.Println(err) ··· 1439 1440 // acls 1441 p, _ := securejoin.SecureJoin(user.Did, forkName) 1442 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1443 if err != nil { 1444 log.Println(err) 1445 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1460 return 1461 } 1462 1463 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1464 - return 1465 } 1466 } 1467 1468 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1469 user := rp.oauth.GetUser(r) 1470 f, err := rp.repoResolver.Resolve(r) ··· 1473 return 1474 } 1475 1476 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1477 - if err != nil { 1478 - log.Printf("failed to create unsigned client for %s", f.Knot) 1479 rp.pages.Error503(w) 1480 return 1481 } 1482 1483 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1484 - if err != nil { 1485 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1486 - log.Println("failed to reach knotserver", err) 1487 return 1488 } 1489 - branches := result.Branches 1490 1491 sortBranches(branches) 1492 ··· 1510 head = queryHead 1511 } 1512 1513 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1514 - if err != nil { 1515 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1516 - log.Println("failed to reach knotserver", err) 1517 return 1518 } 1519 ··· 1565 return 1566 } 1567 1568 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1569 - if err != nil { 1570 - log.Printf("failed to create unsigned client for %s", f.Knot) 1571 rp.pages.Error503(w) 1572 return 1573 } 1574 1575 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1576 - if err != nil { 1577 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1578 - log.Println("failed to reach knotserver", err) 1579 return 1580 } 1581 1582 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1583 - if err != nil { 1584 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1585 - log.Println("failed to reach knotserver", err) 1586 return 1587 } 1588 1589 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1590 - if err != nil { 1591 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1592 - log.Println("failed to compare", err) 1593 return 1594 } 1595 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1596 1597 repoinfo := f.RepoInfo(user)
··· 17 "strings" 18 "time" 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.sh/tangled.sh/core/api/tangled" 24 "tangled.sh/tangled.sh/core/appview/commitverify" 25 "tangled.sh/tangled.sh/core/appview/config" ··· 29 "tangled.sh/tangled.sh/core/appview/pages" 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 31 "tangled.sh/tangled.sh/core/appview/reporesolver" 32 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 "tangled.sh/tangled.sh/core/idresolver" 35 "tangled.sh/tangled.sh/core/patchutil" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 40 41 securejoin "github.com/cyphar/filepath-securejoin" 42 "github.com/go-chi/chi/v5" 43 "github.com/go-git/go-git/v5/plumbing" 44 45 + "github.com/bluesky-social/indigo/atproto/syntax" 46 ) 47 48 type Repo struct { ··· 56 enforcer *rbac.Enforcer 57 notifier notify.Notifier 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 60 } 61 62 func New( ··· 84 } 85 } 86 87 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + ref := chi.URLParam(r, "ref") 89 + ref, _ = url.PathUnescape(ref) 90 + 91 + f, err := rp.repoResolver.Resolve(r) 92 + if err != nil { 93 + log.Println("failed to get repo and knot", err) 94 + return 95 + } 96 + 97 + scheme := "http" 98 + if !rp.config.Core.Dev { 99 + scheme = "https" 100 + } 101 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 102 + xrpcc := &indigoxrpc.Client{ 103 + Host: host, 104 + } 105 + 106 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 107 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 108 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 109 + log.Println("failed to call XRPC repo.archive", xrpcerr) 110 + rp.pages.Error503(w) 111 + return 112 + } 113 + 114 + // Set headers for file download, just pass along whatever the knot specifies 115 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 116 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 117 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 118 + w.Header().Set("Content-Type", "application/gzip") 119 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 120 + 121 + // Write the archive data directly 122 + w.Write(archiveBytes) 123 + } 124 + 125 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 126 f, err := rp.repoResolver.Resolve(r) 127 if err != nil { ··· 138 } 139 140 ref := chi.URLParam(r, "ref") 141 + ref, _ = url.PathUnescape(ref) 142 143 + scheme := "http" 144 + if !rp.config.Core.Dev { 145 + scheme = "https" 146 + } 147 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 148 + xrpcc := &indigoxrpc.Client{ 149 + Host: host, 150 + } 151 + 152 + limit := int64(60) 153 + cursor := "" 154 + if page > 1 { 155 + // Convert page number to cursor (offset) 156 + offset := (page - 1) * int(limit) 157 + cursor = strconv.Itoa(offset) 158 + } 159 + 160 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 161 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 162 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 163 + log.Println("failed to call XRPC repo.log", xrpcerr) 164 + rp.pages.Error503(w) 165 return 166 } 167 168 + var xrpcResp types.RepoLogResponse 169 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 170 + log.Println("failed to decode XRPC response", err) 171 + rp.pages.Error503(w) 172 return 173 } 174 175 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 176 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 177 + log.Println("failed to call XRPC repo.tags", xrpcerr) 178 + rp.pages.Error503(w) 179 return 180 } 181 182 tagMap := make(map[string][]string) 183 + if tagBytes != nil { 184 + var tagResp types.RepoTagsResponse 185 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 186 + for _, tag := range tagResp.Tags { 187 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 188 + } 189 } 190 } 191 192 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 193 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 194 + log.Println("failed to call XRPC repo.branches", xrpcerr) 195 + rp.pages.Error503(w) 196 return 197 } 198 199 + if branchBytes != nil { 200 + var branchResp types.RepoBranchesResponse 201 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 202 + for _, branch := range branchResp.Branches { 203 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 204 + } 205 + } 206 } 207 208 user := rp.oauth.GetUser(r) 209 210 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 211 if err != nil { 212 log.Println("failed to fetch email to did mapping", err) 213 } 214 215 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 216 if err != nil { 217 log.Println(err) 218 } ··· 220 repoInfo := f.RepoInfo(user) 221 222 var shas []string 223 + for _, c := range xrpcResp.Commits { 224 shas = append(shas, c.Hash.String()) 225 } 226 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 233 LoggedInUser: user, 234 TagMap: tagMap, 235 RepoInfo: repoInfo, 236 + RepoLogResponse: xrpcResp, 237 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 238 VerifiedCommits: vc, 239 Pipelines: pipelines, ··· 262 return 263 } 264 265 + repoAt := f.RepoAt() 266 rkey := repoAt.RecordKey().String() 267 if rkey == "" { 268 log.Println("invalid aturi for repo", err) ··· 312 Record: &lexutil.LexiconTypeDecoder{ 313 Val: &tangled.Repo{ 314 Knot: f.Knot, 315 + Name: f.Name, 316 Owner: user.Did, 317 + CreatedAt: f.Created.Format(time.RFC3339), 318 Description: &newDescription, 319 Spindle: &f.Spindle, 320 }, ··· 345 return 346 } 347 ref := chi.URLParam(r, "ref") 348 + ref, _ = url.PathUnescape(ref) 349 350 var diffOpts types.DiffOpts 351 if d := r.URL.Query().Get("diff"); d == "split" { ··· 357 return 358 } 359 360 + scheme := "http" 361 + if !rp.config.Core.Dev { 362 + scheme = "https" 363 + } 364 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 365 + xrpcc := &indigoxrpc.Client{ 366 + Host: host, 367 } 368 369 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 370 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 371 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 372 + log.Println("failed to call XRPC repo.diff", xrpcerr) 373 + rp.pages.Error503(w) 374 return 375 } 376 377 var result types.RepoCommitResponse 378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 379 + log.Println("failed to decode XRPC response", err) 380 + rp.pages.Error503(w) 381 return 382 } 383 ··· 422 } 423 424 ref := chi.URLParam(r, "ref") 425 + ref, _ = url.PathUnescape(ref) 426 + 427 + // if the tree path has a trailing slash, let's strip it 428 + // so we don't 404 429 treePath := chi.URLParam(r, "*") 430 + treePath, _ = url.PathUnescape(treePath) 431 + treePath = strings.TrimSuffix(treePath, "/") 432 + 433 + scheme := "http" 434 if !rp.config.Core.Dev { 435 + scheme = "https" 436 } 437 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 438 + xrpcc := &indigoxrpc.Client{ 439 + Host: host, 440 } 441 442 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 443 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 444 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 445 + log.Println("failed to call XRPC repo.tree", xrpcerr) 446 + rp.pages.Error503(w) 447 return 448 } 449 450 + // Convert XRPC response to internal types.RepoTreeResponse 451 + files := make([]types.NiceTree, len(xrpcResp.Files)) 452 + for i, xrpcFile := range xrpcResp.Files { 453 + file := types.NiceTree{ 454 + Name: xrpcFile.Name, 455 + Mode: xrpcFile.Mode, 456 + Size: int64(xrpcFile.Size), 457 + IsFile: xrpcFile.Is_file, 458 + IsSubtree: xrpcFile.Is_subtree, 459 + } 460 + 461 + // Convert last commit info if present 462 + if xrpcFile.Last_commit != nil { 463 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 464 + file.LastCommit = &types.LastCommitInfo{ 465 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 466 + Message: xrpcFile.Last_commit.Message, 467 + When: commitWhen, 468 + } 469 + } 470 + 471 + files[i] = file 472 + } 473 + 474 + result := types.RepoTreeResponse{ 475 + Ref: xrpcResp.Ref, 476 + Files: files, 477 + } 478 + 479 + if xrpcResp.Parent != nil { 480 + result.Parent = *xrpcResp.Parent 481 + } 482 + if xrpcResp.Dotdot != nil { 483 + result.DotDot = *xrpcResp.Dotdot 484 } 485 486 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 487 // so we can safely redirect to the "parent" (which is the same file). 488 + if len(result.Files) == 0 && result.Parent == treePath { 489 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 490 + http.Redirect(w, r, redirectTo, http.StatusFound) 491 return 492 } 493 494 user := rp.oauth.GetUser(r) 495 496 var breadcrumbs [][]string 497 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 498 if treePath != "" { 499 for idx, elem := range strings.Split(treePath, "/") { 500 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 501 } 502 } 503 ··· 519 return 520 } 521 522 + scheme := "http" 523 + if !rp.config.Core.Dev { 524 + scheme = "https" 525 + } 526 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 527 + xrpcc := &indigoxrpc.Client{ 528 + Host: host, 529 + } 530 + 531 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 532 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 533 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 534 + log.Println("failed to call XRPC repo.tags", xrpcerr) 535 + rp.pages.Error503(w) 536 return 537 } 538 539 + var result types.RepoTagsResponse 540 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 541 + log.Println("failed to decode XRPC response", err) 542 + rp.pages.Error503(w) 543 return 544 } 545 546 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 547 if err != nil { 548 log.Println("failed grab artifacts", err) 549 return ··· 575 rp.pages.RepoTags(w, pages.RepoTagsParams{ 576 LoggedInUser: user, 577 RepoInfo: f.RepoInfo(user), 578 + RepoTagsResponse: result, 579 ArtifactMap: artifactMap, 580 DanglingArtifacts: danglingArtifacts, 581 }) ··· 588 return 589 } 590 591 + scheme := "http" 592 + if !rp.config.Core.Dev { 593 + scheme = "https" 594 + } 595 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 596 + xrpcc := &indigoxrpc.Client{ 597 + Host: host, 598 + } 599 + 600 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 601 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 602 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 603 + log.Println("failed to call XRPC repo.branches", xrpcerr) 604 + rp.pages.Error503(w) 605 return 606 } 607 608 + var result types.RepoBranchesResponse 609 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 610 + log.Println("failed to decode XRPC response", err) 611 + rp.pages.Error503(w) 612 return 613 } 614 ··· 618 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 619 LoggedInUser: user, 620 RepoInfo: f.RepoInfo(user), 621 + RepoBranchesResponse: result, 622 }) 623 } 624 ··· 630 } 631 632 ref := chi.URLParam(r, "ref") 633 + ref, _ = url.PathUnescape(ref) 634 + 635 filePath := chi.URLParam(r, "*") 636 + filePath, _ = url.PathUnescape(filePath) 637 + 638 + scheme := "http" 639 if !rp.config.Core.Dev { 640 + scheme = "https" 641 } 642 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 643 + xrpcc := &indigoxrpc.Client{ 644 + Host: host, 645 } 646 647 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 648 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 649 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 650 + log.Println("failed to call XRPC repo.blob", xrpcerr) 651 + rp.pages.Error503(w) 652 return 653 } 654 655 + // Use XRPC response directly instead of converting to internal types 656 657 var breadcrumbs [][]string 658 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 659 if filePath != "" { 660 for idx, elem := range strings.Split(filePath, "/") { 661 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 662 } 663 } 664 665 showRendered := false 666 renderToggle := false 667 668 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 669 renderToggle = true 670 showRendered = r.URL.Query().Get("code") != "true" 671 } ··· 675 var isVideo bool 676 var contentSrc string 677 678 + if resp.IsBinary != nil && *resp.IsBinary { 679 + ext := strings.ToLower(filepath.Ext(resp.Path)) 680 switch ext { 681 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 682 isImage = true ··· 686 unsupported = true 687 } 688 689 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 690 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 691 692 + baseURL := &url.URL{ 693 + Scheme: scheme, 694 + Host: f.Knot, 695 + Path: "/xrpc/sh.tangled.repo.blob", 696 + } 697 + query := baseURL.Query() 698 + query.Set("repo", repoName) 699 + query.Set("ref", ref) 700 + query.Set("path", filePath) 701 + query.Set("raw", "true") 702 + baseURL.RawQuery = query.Encode() 703 + blobURL := baseURL.String() 704 + 705 contentSrc = blobURL 706 if !rp.config.Core.Dev { 707 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 708 } 709 } 710 711 + lines := 0 712 + if resp.IsBinary == nil || !*resp.IsBinary { 713 + lines = strings.Count(resp.Content, "\n") + 1 714 + } 715 + 716 + var sizeHint uint64 717 + if resp.Size != nil { 718 + sizeHint = uint64(*resp.Size) 719 + } else { 720 + sizeHint = uint64(len(resp.Content)) 721 + } 722 + 723 user := rp.oauth.GetUser(r) 724 + 725 + // Determine if content is binary (dereference pointer) 726 + isBinary := false 727 + if resp.IsBinary != nil { 728 + isBinary = *resp.IsBinary 729 + } 730 + 731 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 732 + LoggedInUser: user, 733 + RepoInfo: f.RepoInfo(user), 734 + BreadCrumbs: breadcrumbs, 735 + ShowRendered: showRendered, 736 + RenderToggle: renderToggle, 737 + Unsupported: unsupported, 738 + IsImage: isImage, 739 + IsVideo: isVideo, 740 + ContentSrc: contentSrc, 741 + RepoBlob_Output: resp, 742 + Contents: resp.Content, 743 + Lines: lines, 744 + SizeHint: sizeHint, 745 + IsBinary: isBinary, 746 }) 747 } 748 ··· 755 } 756 757 ref := chi.URLParam(r, "ref") 758 + ref, _ = url.PathUnescape(ref) 759 + 760 filePath := chi.URLParam(r, "*") 761 + filePath, _ = url.PathUnescape(filePath) 762 763 + scheme := "http" 764 if !rp.config.Core.Dev { 765 + scheme = "https" 766 } 767 + 768 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 769 + baseURL := &url.URL{ 770 + Scheme: scheme, 771 + Host: f.Knot, 772 + Path: "/xrpc/sh.tangled.repo.blob", 773 + } 774 + query := baseURL.Query() 775 + query.Set("repo", repo) 776 + query.Set("ref", ref) 777 + query.Set("path", filePath) 778 + query.Set("raw", "true") 779 + baseURL.RawQuery = query.Encode() 780 + blobURL := baseURL.String() 781 + 782 + req, err := http.NewRequest("GET", blobURL, nil) 783 + if err != nil { 784 + log.Println("failed to create request", err) 785 + return 786 + } 787 + 788 + // forward the If-None-Match header 789 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 790 + req.Header.Set("If-None-Match", clientETag) 791 + } 792 + 793 + client := &http.Client{} 794 + resp, err := client.Do(req) 795 if err != nil { 796 + log.Println("failed to reach knotserver", err) 797 rp.pages.Error503(w) 798 return 799 } 800 defer resp.Body.Close() 801 802 + // forward 304 not modified 803 + if resp.StatusCode == http.StatusNotModified { 804 + w.WriteHeader(http.StatusNotModified) 805 + return 806 + } 807 + 808 if resp.StatusCode != http.StatusOK { 809 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 810 w.WriteHeader(resp.StatusCode) ··· 820 return 821 } 822 823 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 824 + // serve all textual content as text/plain 825 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 826 w.Write(body) 827 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 828 + // serve images and videos with their original content type 829 w.Header().Set("Content-Type", contentType) 830 w.Write(body) 831 } else { ··· 835 } 836 } 837 838 + // isTextualMimeType returns true if the MIME type represents textual content 839 + // that should be served as text/plain 840 + func isTextualMimeType(mimeType string) bool { 841 + textualTypes := []string{ 842 + "application/json", 843 + "application/xml", 844 + "application/yaml", 845 + "application/x-yaml", 846 + "application/toml", 847 + "application/javascript", 848 + "application/ecmascript", 849 + "message/", 850 + } 851 + 852 + return slices.Contains(textualTypes, mimeType) 853 + } 854 + 855 // modify the spindle configured for this repo 856 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 857 user := rp.oauth.GetUser(r) ··· 871 return 872 } 873 874 + repoAt := f.RepoAt() 875 rkey := repoAt.RecordKey().String() 876 if rkey == "" { 877 fail("Failed to resolve repo. Try again later", err) ··· 879 } 880 881 newSpindle := r.FormValue("spindle") 882 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 883 client, err := rp.oauth.AuthorizedClient(r) 884 if err != nil { 885 fail("Failed to authorize. Try again later.", err) 886 return 887 } 888 889 + if !removingSpindle { 890 + // ensure that this is a valid spindle for this user 891 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 892 + if err != nil { 893 + fail("Failed to find spindles. Try again later.", err) 894 + return 895 + } 896 + 897 + if !slices.Contains(validSpindles, newSpindle) { 898 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 899 + return 900 + } 901 } 902 903 + spindlePtr := &newSpindle 904 + if removingSpindle { 905 + spindlePtr = nil 906 } 907 908 // optimistic update 909 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 910 if err != nil { 911 fail("Failed to update spindle. Try again later.", err) 912 return ··· 925 Record: &lexutil.LexiconTypeDecoder{ 926 Val: &tangled.Repo{ 927 Knot: f.Knot, 928 + Name: f.Name, 929 Owner: user.Did, 930 + CreatedAt: f.Created.Format(time.RFC3339), 931 Description: &f.Description, 932 + Spindle: spindlePtr, 933 }, 934 }, 935 }) ··· 939 return 940 } 941 942 + if !removingSpindle { 943 + // add this spindle to spindle stream 944 + rp.spindlestream.AddSource( 945 + context.Background(), 946 + eventconsumer.NewSpindleSource(newSpindle), 947 + ) 948 + } 949 950 rp.pages.HxRefresh(w) 951 } ··· 973 fail("Invalid form.", nil) 974 return 975 } 976 + 977 + // remove a single leading `@`, to make @handle work with ResolveIdent 978 + collaborator = strings.TrimPrefix(collaborator, "@") 979 980 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 981 if err != nil { ··· 987 fail("You seem to be adding yourself as a collaborator.", nil) 988 return 989 } 990 l = l.With("collaborator", collaboratorIdent.Handle) 991 l = l.With("knot", f.Knot) 992 993 + // announce this relation into the firehose, store into owners' pds 994 + client, err := rp.oauth.AuthorizedClient(r) 995 if err != nil { 996 + fail("Failed to write to PDS.", err) 997 return 998 } 999 1000 + // emit a record 1001 + currentUser := rp.oauth.GetUser(r) 1002 + rkey := tid.TID() 1003 + createdAt := time.Now() 1004 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1005 + Collection: tangled.RepoCollaboratorNSID, 1006 + Repo: currentUser.Did, 1007 + Rkey: rkey, 1008 + Record: &lexutil.LexiconTypeDecoder{ 1009 + Val: &tangled.RepoCollaborator{ 1010 + Subject: collaboratorIdent.DID.String(), 1011 + Repo: string(f.RepoAt()), 1012 + CreatedAt: createdAt.Format(time.RFC3339), 1013 + }}, 1014 + }) 1015 + // invalid record 1016 if err != nil { 1017 + fail("Failed to write record to PDS.", err) 1018 return 1019 } 1020 1021 + aturi := resp.Uri 1022 + l = l.With("at-uri", aturi) 1023 + l.Info("wrote record to PDS") 1024 1025 tx, err := rp.db.BeginTx(r.Context(), nil) 1026 if err != nil { 1027 fail("Failed to add collaborator.", err) 1028 return 1029 } 1030 + 1031 + rollback := func() { 1032 + err1 := tx.Rollback() 1033 + err2 := rp.enforcer.E.LoadPolicy() 1034 + err3 := rollbackRecord(context.Background(), aturi, client) 1035 + 1036 + // ignore txn complete errors, this is okay 1037 + if errors.Is(err1, sql.ErrTxDone) { 1038 + err1 = nil 1039 } 1040 + 1041 + if errs := errors.Join(err1, err2, err3); errs != nil { 1042 + l.Error("failed to rollback changes", "errs", errs) 1043 + return 1044 + } 1045 + } 1046 + defer rollback() 1047 1048 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 1049 if err != nil { ··· 1051 return 1052 } 1053 1054 + err = db.AddCollaborator(rp.db, db.Collaborator{ 1055 + Did: syntax.DID(currentUser.Did), 1056 + Rkey: rkey, 1057 + SubjectDid: collaboratorIdent.DID, 1058 + RepoAt: f.RepoAt(), 1059 + Created: createdAt, 1060 + }) 1061 if err != nil { 1062 fail("Failed to add collaborator.", err) 1063 return ··· 1075 return 1076 } 1077 1078 + // clear aturi to when everything is successful 1079 + aturi = "" 1080 + 1081 rp.pages.HxRefresh(w) 1082 } 1083 1084 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1085 user := rp.oauth.GetUser(r) 1086 1087 + noticeId := "operation-error" 1088 f, err := rp.repoResolver.Resolve(r) 1089 if err != nil { 1090 log.Println("failed to get repo and knot", err) ··· 1097 log.Println("failed to get authorized client", err) 1098 return 1099 } 1100 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1101 Collection: tangled.RepoNSID, 1102 Repo: user.Did, 1103 + Rkey: f.Rkey, 1104 }) 1105 if err != nil { 1106 log.Printf("failed to delete record: %s", err) 1107 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1108 return 1109 } 1110 + log.Println("removed repo record ", f.RepoAt().String()) 1111 1112 + client, err := rp.oauth.ServiceClient( 1113 + r, 1114 + oauth.WithService(f.Knot), 1115 + oauth.WithLxm(tangled.RepoDeleteNSID), 1116 + oauth.WithDev(rp.config.Core.Dev), 1117 + ) 1118 if err != nil { 1119 + log.Println("failed to connect to knot server:", err) 1120 return 1121 } 1122 1123 + err = tangled.RepoDelete( 1124 + r.Context(), 1125 + client, 1126 + &tangled.RepoDelete_Input{ 1127 + Did: f.OwnerDid(), 1128 + Name: f.Name, 1129 + Rkey: f.Rkey, 1130 + }, 1131 + ) 1132 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1133 + rp.pages.Notice(w, noticeId, err.Error()) 1134 return 1135 } 1136 + log.Println("deleted repo from knot") 1137 1138 tx, err := rp.db.BeginTx(r.Context(), nil) 1139 if err != nil { ··· 1152 // remove collaborator RBAC 1153 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1154 if err != nil { 1155 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 1156 return 1157 } 1158 for _, c := range repoCollaborators { ··· 1164 // remove repo RBAC 1165 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1166 if err != nil { 1167 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1168 return 1169 } 1170 1171 // remove repo from db 1172 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 1173 if err != nil { 1174 + rp.pages.Notice(w, noticeId, "Failed to update appview") 1175 return 1176 } 1177 log.Println("removed repo from db") ··· 1200 return 1201 } 1202 1203 + noticeId := "operation-error" 1204 branch := r.FormValue("branch") 1205 if branch == "" { 1206 http.Error(w, "malformed form", http.StatusBadRequest) 1207 return 1208 } 1209 1210 + client, err := rp.oauth.ServiceClient( 1211 + r, 1212 + oauth.WithService(f.Knot), 1213 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1214 + oauth.WithDev(rp.config.Core.Dev), 1215 + ) 1216 if err != nil { 1217 + log.Println("failed to connect to knot server:", err) 1218 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1219 return 1220 } 1221 1222 + xe := tangled.RepoSetDefaultBranch( 1223 + r.Context(), 1224 + client, 1225 + &tangled.RepoSetDefaultBranch_Input{ 1226 + Repo: f.RepoAt().String(), 1227 + DefaultBranch: branch, 1228 + }, 1229 + ) 1230 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1231 + log.Println("xrpc failed", "err", xe) 1232 + rp.pages.Notice(w, noticeId, err.Error()) 1233 return 1234 } 1235 1236 + rp.pages.HxRefresh(w) 1237 } 1238 1239 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1262 r, 1263 oauth.WithService(f.Spindle), 1264 oauth.WithLxm(lxm), 1265 + oauth.WithExp(60), 1266 oauth.WithDev(rp.config.Core.Dev), 1267 ) 1268 if err != nil { ··· 1290 r.Context(), 1291 spindleClient, 1292 &tangled.RepoAddSecret_Input{ 1293 + Repo: f.RepoAt().String(), 1294 Key: key, 1295 Value: value, 1296 }, ··· 1308 r.Context(), 1309 spindleClient, 1310 &tangled.RepoRemoveSecret_Input{ 1311 + Repo: f.RepoAt().String(), 1312 Key: key, 1313 }, 1314 ) ··· 1349 case "pipelines": 1350 rp.pipelineSettings(w, r) 1351 } 1352 } 1353 1354 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1355 f, err := rp.repoResolver.Resolve(r) 1356 user := rp.oauth.GetUser(r) 1357 1358 + scheme := "http" 1359 + if !rp.config.Core.Dev { 1360 + scheme = "https" 1361 + } 1362 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1363 + xrpcc := &indigoxrpc.Client{ 1364 + Host: host, 1365 + } 1366 + 1367 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1368 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1369 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1370 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1371 + rp.pages.Error503(w) 1372 return 1373 } 1374 1375 + var result types.RepoBranchesResponse 1376 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1377 + log.Println("failed to decode XRPC response", err) 1378 + rp.pages.Error503(w) 1379 return 1380 } 1381 ··· 1410 f, err := rp.repoResolver.Resolve(r) 1411 user := rp.oauth.GetUser(r) 1412 1413 + // all spindles that the repo owner is a member of 1414 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1415 if err != nil { 1416 log.Println("failed to fetch spindles", err) 1417 return ··· 1423 r, 1424 oauth.WithService(f.Spindle), 1425 oauth.WithLxm(tangled.RepoListSecretsNSID), 1426 + oauth.WithExp(60), 1427 oauth.WithDev(rp.config.Core.Dev), 1428 ); err != nil { 1429 log.Println("failed to create spindle client", err) 1430 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1431 log.Println("failed to fetch secrets", err) 1432 } else { 1433 secrets = resp.Secrets ··· 1468 } 1469 1470 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1471 + ref := chi.URLParam(r, "ref") 1472 + ref, _ = url.PathUnescape(ref) 1473 + 1474 user := rp.oauth.GetUser(r) 1475 f, err := rp.repoResolver.Resolve(r) 1476 if err != nil { ··· 1480 1481 switch r.Method { 1482 case http.MethodPost: 1483 + client, err := rp.oauth.ServiceClient( 1484 + r, 1485 + oauth.WithService(f.Knot), 1486 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1487 + oauth.WithDev(rp.config.Core.Dev), 1488 + ) 1489 if err != nil { 1490 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1491 return 1492 } 1493 1494 + repoInfo := f.RepoInfo(user) 1495 + if repoInfo.Source == nil { 1496 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1497 return 1498 } 1499 1500 + err = tangled.RepoForkSync( 1501 + r.Context(), 1502 + client, 1503 + &tangled.RepoForkSync_Input{ 1504 + Did: user.Did, 1505 + Name: f.Name, 1506 + Source: repoInfo.Source.RepoAt().String(), 1507 + Branch: ref, 1508 + }, 1509 + ) 1510 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1511 + rp.pages.Notice(w, "repo", err.Error()) 1512 return 1513 } 1514 ··· 1541 }) 1542 1543 case http.MethodPost: 1544 + l := rp.logger.With("handler", "ForkRepo") 1545 1546 + targetKnot := r.FormValue("knot") 1547 + if targetKnot == "" { 1548 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1549 return 1550 } 1551 + l = l.With("targetKnot", targetKnot) 1552 1553 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1554 if err != nil || !ok { 1555 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1556 return 1557 } 1558 1559 + // choose a name for a fork 1560 + forkName := f.Name 1561 // this check is *only* to see if the forked repo name already exists 1562 // in the user's account. 1563 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1564 if err != nil { 1565 if errors.Is(err, sql.ErrNoRows) { 1566 // no existing repo with this name found, we can use the name as is ··· 1573 // repo with this name already exists, append random string 1574 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1575 } 1576 + l = l.With("forkName", forkName) 1577 1578 + uri := "https" 1579 if rp.config.Core.Dev { 1580 uri = "http" 1581 } 1582 1583 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1584 + l = l.With("cloneUrl", forkSourceUrl) 1585 + 1586 + sourceAt := f.RepoAt().String() 1587 + 1588 + // create an atproto record for this fork 1589 rkey := tid.TID() 1590 repo := &db.Repo{ 1591 Did: user.Did, 1592 Name: forkName, 1593 + Knot: targetKnot, 1594 Rkey: rkey, 1595 Source: sourceAt, 1596 } 1597 1598 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1599 if err != nil { 1600 + l.Error("failed to create xrpcclient", "err", err) 1601 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1602 return 1603 } 1604 ··· 1617 }}, 1618 }) 1619 if err != nil { 1620 + l.Error("failed to write to PDS", "err", err) 1621 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1622 return 1623 } 1624 + 1625 + aturi := atresp.Uri 1626 + l = l.With("aturi", aturi) 1627 + l.Info("wrote to PDS") 1628 + 1629 + tx, err := rp.db.BeginTx(r.Context(), nil) 1630 + if err != nil { 1631 + l.Info("txn failed", "err", err) 1632 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1633 + return 1634 + } 1635 + 1636 + // The rollback function reverts a few things on failure: 1637 + // - the pending txn 1638 + // - the ACLs 1639 + // - the atproto record created 1640 + rollback := func() { 1641 + err1 := tx.Rollback() 1642 + err2 := rp.enforcer.E.LoadPolicy() 1643 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1644 + 1645 + // ignore txn complete errors, this is okay 1646 + if errors.Is(err1, sql.ErrTxDone) { 1647 + err1 = nil 1648 + } 1649 + 1650 + if errs := errors.Join(err1, err2, err3); errs != nil { 1651 + l.Error("failed to rollback changes", "errs", errs) 1652 + return 1653 + } 1654 + } 1655 + defer rollback() 1656 + 1657 + client, err := rp.oauth.ServiceClient( 1658 + r, 1659 + oauth.WithService(targetKnot), 1660 + oauth.WithLxm(tangled.RepoCreateNSID), 1661 + oauth.WithDev(rp.config.Core.Dev), 1662 + ) 1663 + if err != nil { 1664 + l.Error("could not create service client", "err", err) 1665 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1666 + return 1667 + } 1668 + 1669 + err = tangled.RepoCreate( 1670 + r.Context(), 1671 + client, 1672 + &tangled.RepoCreate_Input{ 1673 + Rkey: rkey, 1674 + Source: &forkSourceUrl, 1675 + }, 1676 + ) 1677 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1678 + rp.pages.Notice(w, "repo", err.Error()) 1679 + return 1680 + } 1681 1682 err = db.AddRepo(tx, repo) 1683 if err != nil { 1684 log.Println(err) ··· 1688 1689 // acls 1690 p, _ := securejoin.SecureJoin(user.Did, forkName) 1691 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1692 if err != nil { 1693 log.Println(err) 1694 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1709 return 1710 } 1711 1712 + // reset the ATURI because the transaction completed successfully 1713 + aturi = "" 1714 + 1715 + rp.notifier.NewRepo(r.Context(), repo) 1716 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1717 } 1718 } 1719 1720 + // this is used to rollback changes made to the PDS 1721 + // 1722 + // it is a no-op if the provided ATURI is empty 1723 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1724 + if aturi == "" { 1725 + return nil 1726 + } 1727 + 1728 + parsed := syntax.ATURI(aturi) 1729 + 1730 + collection := parsed.Collection().String() 1731 + repo := parsed.Authority().String() 1732 + rkey := parsed.RecordKey().String() 1733 + 1734 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1735 + Collection: collection, 1736 + Repo: repo, 1737 + Rkey: rkey, 1738 + }) 1739 + return err 1740 + } 1741 + 1742 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1743 user := rp.oauth.GetUser(r) 1744 f, err := rp.repoResolver.Resolve(r) ··· 1747 return 1748 } 1749 1750 + scheme := "http" 1751 + if !rp.config.Core.Dev { 1752 + scheme = "https" 1753 + } 1754 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1755 + xrpcc := &indigoxrpc.Client{ 1756 + Host: host, 1757 + } 1758 + 1759 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1760 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1761 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1762 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1763 rp.pages.Error503(w) 1764 return 1765 } 1766 1767 + var branchResult types.RepoBranchesResponse 1768 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1769 + log.Println("failed to decode XRPC branches response", err) 1770 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1771 return 1772 } 1773 + branches := branchResult.Branches 1774 1775 sortBranches(branches) 1776 ··· 1794 head = queryHead 1795 } 1796 1797 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1798 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1799 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1800 + rp.pages.Error503(w) 1801 + return 1802 + } 1803 + 1804 + var tags types.RepoTagsResponse 1805 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1806 + log.Println("failed to decode XRPC tags response", err) 1807 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1808 return 1809 } 1810 ··· 1856 return 1857 } 1858 1859 + scheme := "http" 1860 + if !rp.config.Core.Dev { 1861 + scheme = "https" 1862 + } 1863 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1864 + xrpcc := &indigoxrpc.Client{ 1865 + Host: host, 1866 + } 1867 + 1868 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1869 + 1870 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1871 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1872 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1873 rp.pages.Error503(w) 1874 return 1875 } 1876 1877 + var branches types.RepoBranchesResponse 1878 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1879 + log.Println("failed to decode XRPC branches response", err) 1880 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1881 return 1882 } 1883 1884 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1885 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1886 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1887 + rp.pages.Error503(w) 1888 + return 1889 + } 1890 + 1891 + var tags types.RepoTagsResponse 1892 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1893 + log.Println("failed to decode XRPC tags response", err) 1894 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1895 return 1896 } 1897 1898 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1899 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1900 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1901 + rp.pages.Error503(w) 1902 + return 1903 + } 1904 + 1905 + var formatPatch types.RepoFormatPatchResponse 1906 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1907 + log.Println("failed to decode XRPC compare response", err) 1908 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1909 return 1910 } 1911 + 1912 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1913 1914 repoinfo := f.RepoInfo(user)
+5
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/commits/{ref}", rp.RepoLog) 14 r.Route("/tree/{ref}", func(r chi.Router) { 15 r.Get("/", rp.RepoIndex) ··· 37 }) 38 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 40 41 r.Route("/fork", func(r chi.Router) { 42 r.Use(middleware.AuthMiddleware(rp.oauth))
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) { 16 r.Get("/", rp.RepoIndex) ··· 38 }) 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 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) 45 46 r.Route("/fork", func(r chi.Router) { 47 r.Use(middleware.AuthMiddleware(rp.oauth))
+37 -104
appview/reporesolver/resolver.go
··· 7 "fmt" 8 "log" 9 "net/http" 10 - "net/url" 11 "path" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" ··· 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) 27 28 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 38 39 rr *RepoResolver 40 } ··· 51 } 52 53 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 56 if !ok { 57 - log.Println("malformed middleware") 58 return nil, fmt.Errorf("malformed middleware") 59 } 60 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 return nil, fmt.Errorf("malformed middleware") 64 } 65 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 - 78 ref := chi.URLParam(r, "ref") 79 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 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, 111 112 rr: rr, 113 }, nil ··· 126 127 var p string 128 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 130 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 132 } 133 134 - return p 135 - } 136 - 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 return p 140 } 141 ··· 187 // this function is a bit weird since it now returns RepoInfo from an entirely different 188 // package. we should refactor this or get rid of RepoInfo entirely. 189 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 190 isStarred := false 191 if user != nil { 192 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 193 } 194 195 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 196 if err != nil { 197 - log.Println("failed to get star count for ", f.RepoAt) 198 } 199 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 200 if err != nil { 201 - log.Println("failed to get issue count for ", f.RepoAt) 202 } 203 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 204 if err != nil { 205 - log.Println("failed to get issue count for ", f.RepoAt) 206 } 207 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 208 if errors.Is(err, sql.ErrNoRows) { 209 source = "" 210 } else if err != nil { 211 - log.Println("failed to get repo source for ", f.RepoAt, err) 212 } 213 214 var sourceRepo *db.Repo ··· 228 } 229 230 knot := f.Knot 231 - var disableFork bool 232 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 233 - if err != nil { 234 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 235 - } else { 236 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 237 - if err != nil { 238 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 239 - } 240 - 241 - if len(result.Branches) == 0 { 242 - disableFork = true 243 - } 244 - } 245 246 repoInfo := repoinfo.RepoInfo{ 247 OwnerDid: f.OwnerDid(), 248 OwnerHandle: f.OwnerHandle(), 249 - Name: f.RepoName, 250 - RepoAt: f.RepoAt, 251 Description: f.Description, 252 - Ref: f.Ref, 253 IsStarred: isStarred, 254 Knot: knot, 255 Spindle: f.Spindle, ··· 259 IssueCount: issueCount, 260 PullCount: pullCount, 261 }, 262 - DisableFork: disableFork, 263 - CurrentDir: f.CurrentDir, 264 } 265 266 if sourceRepo != nil { ··· 284 // after the ref. for example: 285 // 286 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 287 - func extractPathAfterRef(fullPath, ref string) string { 288 fullPath = strings.TrimPrefix(fullPath, "/") 289 290 - ref = url.PathEscape(ref) 291 292 - prefixes := []string{ 293 - fmt.Sprintf("blob/%s/", ref), 294 - fmt.Sprintf("tree/%s/", ref), 295 - fmt.Sprintf("raw/%s/", ref), 296 - } 297 298 - for _, prefix := range prefixes { 299 - idx := strings.Index(fullPath, prefix) 300 - if idx != -1 { 301 - return fullPath[idx+len(prefix):] 302 - } 303 } 304 305 return ""
··· 7 "fmt" 8 "log" 9 "net/http" 10 "path" 11 + "regexp" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" 17 "tangled.sh/tangled.sh/core/appview/config" ··· 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 22 "tangled.sh/tangled.sh/core/idresolver" 23 "tangled.sh/tangled.sh/core/rbac" 24 ) 25 26 type ResolvedRepo struct { 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 31 32 rr *RepoResolver 33 } ··· 44 } 45 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 48 if !ok { 49 + log.Println("malformed middleware: `repo` not exist in context") 50 return nil, fmt.Errorf("malformed middleware") 51 } 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 55 return nil, fmt.Errorf("malformed middleware") 56 } 57 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 59 ref := chi.URLParam(r, "ref") 60 61 return &ResolvedRepo{ 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 66 67 rr: rr, 68 }, nil ··· 81 82 var p string 83 if handle != "" && !handle.IsInvalidHandle() { 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 85 } else { 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 87 } 88 89 return p 90 } 91 ··· 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 138 // package. we should refactor this or get rid of RepoInfo entirely. 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 141 isStarred := false 142 if user != nil { 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 144 } 145 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 147 if err != nil { 148 + log.Println("failed to get star count for ", repoAt) 149 } 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 151 if err != nil { 152 + log.Println("failed to get issue count for ", repoAt) 153 } 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 155 if err != nil { 156 + log.Println("failed to get issue count for ", repoAt) 157 } 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 159 if errors.Is(err, sql.ErrNoRows) { 160 source = "" 161 } else if err != nil { 162 + log.Println("failed to get repo source for ", repoAt, err) 163 } 164 165 var sourceRepo *db.Repo ··· 179 } 180 181 knot := f.Knot 182 183 repoInfo := repoinfo.RepoInfo{ 184 OwnerDid: f.OwnerDid(), 185 OwnerHandle: f.OwnerHandle(), 186 + Name: f.Name, 187 + RepoAt: repoAt, 188 Description: f.Description, 189 IsStarred: isStarred, 190 Knot: knot, 191 Spindle: f.Spindle, ··· 195 IssueCount: issueCount, 196 PullCount: pullCount, 197 }, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 200 } 201 202 if sourceRepo != nil { ··· 220 // after the ref. for example: 221 // 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 223 + func extractPathAfterRef(fullPath string) string { 224 fullPath = strings.TrimPrefix(fullPath, "/") 225 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)/[^/]+/(.*)$` 230 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 233 234 + if len(matches) > 1 { 235 + return matches[1] 236 } 237 238 return ""
+148
appview/serververify/verify.go
···
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 + "tangled.sh/tangled.sh/core/rbac" 13 + ) 14 + 15 + var ( 16 + FetchError = errors.New("failed to fetch owner") 17 + ) 18 + 19 + // fetchOwner fetches the owner DID from a server's /owner endpoint 20 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 21 + scheme := "https" 22 + if dev { 23 + scheme = "http" 24 + } 25 + 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 29 + } 30 + 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 34 + } 35 + 36 + return res.Owner, nil 37 + } 38 + 39 + type OwnerMismatch struct { 40 + expected string 41 + observed string 42 + } 43 + 44 + func (e *OwnerMismatch) Error() string { 45 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 46 + } 47 + 48 + // RunVerification verifies that the server at the given domain has the expected owner 49 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 50 + observedOwner, err := fetchOwner(ctx, domain, dev) 51 + if err != nil { 52 + return err 53 + } 54 + 55 + if observedOwner != expectedOwner { 56 + return &OwnerMismatch{ 57 + expected: expectedOwner, 58 + observed: observedOwner, 59 + } 60 + } 61 + 62 + return nil 63 + } 64 + 65 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 66 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 67 + tx, err := d.Begin() 68 + if err != nil { 69 + return 0, fmt.Errorf("failed to create txn: %w", err) 70 + } 71 + defer func() { 72 + tx.Rollback() 73 + e.E.LoadPolicy() 74 + }() 75 + 76 + // mark this spindle as verified in the db 77 + rowId, err := db.VerifySpindle( 78 + tx, 79 + db.FilterEq("owner", owner), 80 + db.FilterEq("instance", instance), 81 + ) 82 + if err != nil { 83 + return 0, fmt.Errorf("failed to write to DB: %w", err) 84 + } 85 + 86 + err = e.AddSpindleOwner(instance, owner) 87 + if err != nil { 88 + return 0, fmt.Errorf("failed to update ACL: %w", err) 89 + } 90 + 91 + err = tx.Commit() 92 + if err != nil { 93 + return 0, fmt.Errorf("failed to commit txn: %w", err) 94 + } 95 + 96 + err = e.E.SavePolicy() 97 + if err != nil { 98 + return 0, fmt.Errorf("failed to update ACL: %w", err) 99 + } 100 + 101 + return rowId, nil 102 + } 103 + 104 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 105 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 106 + tx, err := d.BeginTx(context.Background(), nil) 107 + if err != nil { 108 + return fmt.Errorf("failed to start tx: %w", err) 109 + } 110 + defer func() { 111 + tx.Rollback() 112 + e.E.LoadPolicy() 113 + }() 114 + 115 + // mark as registered 116 + err = db.MarkRegistered( 117 + tx, 118 + db.FilterEq("did", owner), 119 + db.FilterEq("domain", domain), 120 + ) 121 + if err != nil { 122 + return fmt.Errorf("failed to register domain: %w", err) 123 + } 124 + 125 + // add basic acls for this domain 126 + err = e.AddKnot(domain) 127 + if err != nil { 128 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 129 + } 130 + 131 + // add this did as owner of this domain 132 + err = e.AddKnotOwner(domain, owner) 133 + if err != nil { 134 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 135 + } 136 + 137 + err = tx.Commit() 138 + if err != nil { 139 + return fmt.Errorf("failed to commit changes: %w", err) 140 + } 141 + 142 + err = e.E.SavePolicy() 143 + if err != nil { 144 + return fmt.Errorf("failed to update ACLs: %w", err) 145 + } 146 + 147 + return nil 148 + }
+44 -9
appview/settings/settings.go
··· 33 Config *config.Config 34 } 35 36 func (s *Settings) Router() http.Handler { 37 r := chi.NewRouter() 38 39 r.Use(middleware.AuthMiddleware(s.OAuth)) 40 41 - r.Get("/", s.settings) 42 43 r.Route("/keys", func(r chi.Router) { 44 r.Put("/", s.keys) 45 r.Delete("/", s.keys) 46 }) 47 48 r.Route("/emails", func(r chi.Router) { 49 r.Put("/", s.emails) 50 r.Delete("/", s.emails) 51 r.Get("/verify", s.emailsVerify) ··· 56 return r 57 } 58 59 - func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 60 user := s.OAuth.GetUser(r) 61 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 62 if err != nil { 63 log.Println(err) 64 } 65 66 emails, err := db.GetAllEmails(s.Db, user.Did) 67 if err != nil { 68 log.Println(err) 69 } 70 71 - s.Pages.Settings(w, pages.SettingsParams{ 72 LoggedInUser: user, 73 - PubKeys: pubKeys, 74 Emails: emails, 75 }) 76 } 77 ··· 201 return 202 } 203 204 - s.Pages.HxLocation(w, "/settings") 205 return 206 } 207 } ··· 244 return 245 } 246 247 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 248 } 249 250 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 339 return 340 } 341 342 - s.Pages.HxLocation(w, "/settings") 343 } 344 345 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 410 return 411 } 412 413 - s.Pages.HxLocation(w, "/settings") 414 return 415 416 case http.MethodDelete: ··· 455 } 456 log.Println("deleted successfully") 457 458 - s.Pages.HxLocation(w, "/settings") 459 return 460 } 461 }
··· 33 Config *config.Config 34 } 35 36 + type tab = map[string]any 37 + 38 + var ( 39 + settingsTabs []tab = []tab{ 40 + {"Name": "profile", "Icon": "user"}, 41 + {"Name": "keys", "Icon": "key"}, 42 + {"Name": "emails", "Icon": "mail"}, 43 + } 44 + ) 45 + 46 func (s *Settings) Router() http.Handler { 47 r := chi.NewRouter() 48 49 r.Use(middleware.AuthMiddleware(s.OAuth)) 50 51 + // settings pages 52 + r.Get("/", s.profileSettings) 53 + r.Get("/profile", s.profileSettings) 54 55 r.Route("/keys", func(r chi.Router) { 56 + r.Get("/", s.keysSettings) 57 r.Put("/", s.keys) 58 r.Delete("/", s.keys) 59 }) 60 61 r.Route("/emails", func(r chi.Router) { 62 + r.Get("/", s.emailsSettings) 63 r.Put("/", s.emails) 64 r.Delete("/", s.emails) 65 r.Get("/verify", s.emailsVerify) ··· 70 return r 71 } 72 73 + func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 + user := s.OAuth.GetUser(r) 75 + 76 + s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 + LoggedInUser: user, 78 + Tabs: settingsTabs, 79 + Tab: "profile", 80 + }) 81 + } 82 + 83 + func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 84 user := s.OAuth.GetUser(r) 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 86 if err != nil { 87 log.Println(err) 88 } 89 90 + s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 + LoggedInUser: user, 92 + PubKeys: pubKeys, 93 + Tabs: settingsTabs, 94 + Tab: "keys", 95 + }) 96 + } 97 + 98 + func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 + user := s.OAuth.GetUser(r) 100 emails, err := db.GetAllEmails(s.Db, user.Did) 101 if err != nil { 102 log.Println(err) 103 } 104 105 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 106 LoggedInUser: user, 107 Emails: emails, 108 + Tabs: settingsTabs, 109 + Tab: "emails", 110 }) 111 } 112 ··· 236 return 237 } 238 239 + s.Pages.HxLocation(w, "/settings/emails") 240 return 241 } 242 } ··· 279 return 280 } 281 282 + http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 283 } 284 285 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 374 return 375 } 376 377 + s.Pages.HxLocation(w, "/settings/emails") 378 } 379 380 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 445 return 446 } 447 448 + s.Pages.HxLocation(w, "/settings/keys") 449 return 450 451 case http.MethodDelete: ··· 490 } 491 log.Println("deleted successfully") 492 493 + s.Pages.HxLocation(w, "/settings/keys") 494 return 495 } 496 }
+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 + }
+14 -26
appview/spindles/spindles.go
··· 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 113 return 114 } 115 116 - identsToResolve := make([]string, len(members)) 117 - copy(identsToResolve, members) 118 - resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 119 - didHandleMap := make(map[string]string) 120 - for _, identity := range resolvedIds { 121 - if !identity.Handle.IsInvalidHandle() { 122 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 123 - } else { 124 - didHandleMap[identity.DID.String()] = identity.DID.String() 125 - } 126 - } 127 - 128 // organize repos by did 129 repoMap := make(map[string][]db.Repo) 130 for _, r := range repos { ··· 136 Spindle: spindle, 137 Members: members, 138 Repos: repoMap, 139 - DidHandleMap: didHandleMap, 140 }) 141 } 142 ··· 240 } 241 242 // begin verification 243 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 if err != nil { 245 l.Error("verification failed", "err", err) 246 s.Pages.HxRefresh(w) 247 return 248 } 249 250 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 251 if err != nil { 252 l.Error("failed to mark verified", "err", err) 253 s.Pages.HxRefresh(w) ··· 413 } 414 415 // begin verification 416 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 417 if err != nil { 418 l.Error("verification failed", "err", err) 419 420 - if errors.Is(err, verify.FetchError) { 421 - s.Pages.Notice(w, noticeId, err.Error()) 422 return 423 } 424 425 - if e, ok := err.(*verify.OwnerMismatch); ok { 426 s.Pages.Notice(w, noticeId, e.Error()) 427 return 428 } ··· 431 return 432 } 433 434 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 435 if err != nil { 436 l.Error("failed to mark verified", "err", err) 437 s.Pages.Notice(w, noticeId, err.Error()) ··· 455 } 456 457 w.Header().Set("HX-Reswap", "outerHTML") 458 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 459 } 460 461 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { ··· 619 620 if string(spindles[0].Owner) != user.Did { 621 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 622 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 623 return 624 } 625 626 member := r.FormValue("member") 627 if member == "" { 628 l.Error("empty member") 629 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 630 return 631 } 632 l = l.With("member", member) ··· 634 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 635 if err != nil { 636 l.Error("failed to resolve member identity to handle", "err", err) 637 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 638 return 639 } 640 if memberId.Handle.IsInvalidHandle() { 641 l.Error("failed to resolve member identity to handle") 642 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 643 return 644 } 645
··· 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/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 "tangled.sh/tangled.sh/core/tid" ··· 114 return 115 } 116 117 // organize repos by did 118 repoMap := make(map[string][]db.Repo) 119 for _, r := range repos { ··· 125 Spindle: spindle, 126 Members: members, 127 Repos: repoMap, 128 }) 129 } 130 ··· 228 } 229 230 // begin verification 231 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 232 if err != nil { 233 l.Error("verification failed", "err", err) 234 s.Pages.HxRefresh(w) 235 return 236 } 237 238 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 239 if err != nil { 240 l.Error("failed to mark verified", "err", err) 241 s.Pages.HxRefresh(w) ··· 401 } 402 403 // begin verification 404 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 405 if err != nil { 406 l.Error("verification failed", "err", err) 407 408 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 409 + s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 410 return 411 } 412 413 + if e, ok := err.(*serververify.OwnerMismatch); ok { 414 s.Pages.Notice(w, noticeId, e.Error()) 415 return 416 } ··· 419 return 420 } 421 422 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 423 if err != nil { 424 l.Error("failed to mark verified", "err", err) 425 s.Pages.Notice(w, noticeId, err.Error()) ··· 443 } 444 445 w.Header().Set("HX-Reswap", "outerHTML") 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 447 } 448 449 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { ··· 607 608 if string(spindles[0].Owner) != user.Did { 609 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 610 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 611 return 612 } 613 614 member := r.FormValue("member") 615 if member == "" { 616 l.Error("empty member") 617 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 618 return 619 } 620 l = l.With("member", member) ··· 622 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 623 if err != nil { 624 l.Error("failed to resolve member identity to handle", "err", err) 625 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 626 return 627 } 628 if memberId.Handle.IsInvalidHandle() { 629 l.Error("failed to resolve member identity to handle") 630 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 631 return 632 } 633
-118
appview/spindleverify/verify.go
··· 1 - package spindleverify 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 - 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - ) 15 - 16 - var ( 17 - FetchError = errors.New("failed to fetch owner") 18 - ) 19 - 20 - // TODO: move this to "spindleclient" or similar 21 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 - scheme := "https" 23 - if dev { 24 - scheme = "http" 25 - } 26 - 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 - } 41 - 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 - } 46 - 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 - } 54 - 55 - type OwnerMismatch struct { 56 - expected string 57 - observed string 58 - } 59 - 60 - func (e *OwnerMismatch) Error() string { 61 - return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 - } 63 - 64 - func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 - // begin verification 66 - observedOwner, err := fetchOwner(ctx, instance, dev) 67 - if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 - } 70 - 71 - if observedOwner != expectedOwner { 72 - return &OwnerMismatch{ 73 - expected: expectedOwner, 74 - observed: observedOwner, 75 - } 76 - } 77 - 78 - return nil 79 - } 80 - 81 - // mark this spindle as verified in the DB and add this user as its owner 82 - func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 - tx, err := d.Begin() 84 - if err != nil { 85 - return 0, fmt.Errorf("failed to create txn: %w", err) 86 - } 87 - defer func() { 88 - tx.Rollback() 89 - e.E.LoadPolicy() 90 - }() 91 - 92 - // mark this spindle as verified in the db 93 - rowId, err := db.VerifySpindle( 94 - tx, 95 - db.FilterEq("owner", owner), 96 - db.FilterEq("instance", instance), 97 - ) 98 - if err != nil { 99 - return 0, fmt.Errorf("failed to write to DB: %w", err) 100 - } 101 - 102 - err = e.AddSpindleOwner(instance, owner) 103 - if err != nil { 104 - return 0, fmt.Errorf("failed to update ACL: %w", err) 105 - } 106 - 107 - err = tx.Commit() 108 - if err != nil { 109 - return 0, fmt.Errorf("failed to commit txn: %w", err) 110 - } 111 - 112 - err = e.E.SavePolicy() 113 - if err != nil { 114 - return 0, fmt.Errorf("failed to update ACL: %w", err) 115 - } 116 - 117 - return rowId, nil 118 - }
···
+9 -12
appview/state/git_http.go
··· 3 import ( 4 "fmt" 5 "io" 6 "net/http" 7 8 "github.com/bluesky-social/indigo/atproto/identity" 9 "github.com/go-chi/chi/v5" 10 ) 11 12 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 17 scheme := "https" 18 if s.config.Core.Dev { 19 scheme = "http" 20 } 21 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 s.proxyRequest(w, r, targetURL) 24 25 } ··· 30 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 return 32 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 35 36 scheme := "https" 37 if s.config.Core.Dev { 38 scheme = "http" 39 } 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 42 s.proxyRequest(w, r, targetURL) 43 } 44 ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 53 54 scheme := "https" 55 if s.config.Core.Dev { 56 scheme = "http" 57 } 58 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 60 s.proxyRequest(w, r, targetURL) 61 } 62 ··· 85 defer resp.Body.Close() 86 87 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 91 92 // Set response status code 93 w.WriteHeader(resp.StatusCode)
··· 3 import ( 4 "fmt" 5 "io" 6 + "maps" 7 "net/http" 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 + repo := r.Context().Value("repo").(*db.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { 20 scheme = "http" 21 } 22 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 24 s.proxyRequest(w, r, targetURL) 25 26 } ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 + repo := r.Context().Value("repo").(*db.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { 38 scheme = "http" 39 } 40 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 s.proxyRequest(w, r, targetURL) 43 } 44 ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 + repo := r.Context().Value("repo").(*db.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev { 55 scheme = "http" 56 } 57 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 59 s.proxyRequest(w, r, targetURL) 60 } 61 ··· 84 defer resp.Body.Close() 85 86 // Copy response headers 87 + maps.Copy(w.Header(), resp.Header) 88 89 // Set response status code 90 w.WriteHeader(resp.StatusCode)
+5 -2
appview/state/knotstream.go
··· 24 ) 25 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetCompletedRegistrations(d) 28 if err != nil { 29 return nil, err 30 } 31 32 srcs := make(map[ec.Source]struct{}) 33 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 35 srcs[s] = struct{}{} 36 } 37
··· 24 ) 25 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 31 if err != nil { 32 return nil, err 33 } 34 35 srcs := make(map[ec.Source]struct{}) 36 for _, k := range knots { 37 + s := ec.NewKnotSource(k.Domain) 38 srcs[s] = struct{}{} 39 } 40
+418 -126
appview/state/profile.go
··· 1 package state 2 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 "fmt" 8 "log" 9 "net/http" ··· 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/pages" ··· 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 tabVal := r.URL.Query().Get("tab") 26 switch tabVal { 27 - case "": 28 - s.profilePage(w, r) 29 case "repos": 30 s.reposPage(w, r) 31 } 32 } 33 34 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 35 didOrHandle := chi.URLParam(r, "user") 36 if didOrHandle == "" { 37 - http.Error(w, "Bad request", http.StatusBadRequest) 38 - return 39 } 40 41 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 42 if !ok { 43 - s.pages.Error404(w) 44 - return 45 } 46 47 - profile, err := db.GetProfile(s.db, ident.DID.String()) 48 if err != nil { 49 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 50 } 51 52 repos, err := db.GetRepos( 53 s.db, 54 0, 55 - db.FilterEq("did", ident.DID.String()), 56 ) 57 if err != nil { 58 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 59 } 60 61 // filter out ones that are pinned 62 pinnedRepos := []db.Repo{} 63 for i, r := range repos { 64 // if this is a pinned repo, add it 65 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 66 pinnedRepos = append(pinnedRepos, r) 67 } 68 69 // if there are no saved pins, add the first 4 repos 70 - if profile.IsPinnedReposEmpty() && i < 4 { 71 pinnedRepos = append(pinnedRepos, r) 72 } 73 } 74 75 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 76 if err != nil { 77 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 78 } 79 80 pinnedCollaboratingRepos := []db.Repo{} 81 for _, r := range collaboratingRepos { 82 // if this is a pinned repo, add it 83 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 84 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 85 } 86 } 87 88 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 89 if err != nil { 90 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 91 } 92 93 - var didsToResolve []string 94 - for _, r := range collaboratingRepos { 95 - didsToResolve = append(didsToResolve, r.Did) 96 } 97 - for _, byMonth := range timeline.ByMonth { 98 - for _, pe := range byMonth.PullEvents.Items { 99 - didsToResolve = append(didsToResolve, pe.Repo.Did) 100 - } 101 - for _, ie := range byMonth.IssueEvents.Items { 102 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 103 } 104 - for _, re := range byMonth.RepoEvents { 105 - didsToResolve = append(didsToResolve, re.Repo.Did) 106 - if re.Source != nil { 107 - didsToResolve = append(didsToResolve, re.Source.Did) 108 - } 109 } 110 } 111 112 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 113 - didHandleMap := make(map[string]string) 114 - for _, identity := range resolvedIds { 115 - if !identity.Handle.IsInvalidHandle() { 116 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 117 } else { 118 - didHandleMap[identity.DID.String()] = identity.DID.String() 119 } 120 } 121 122 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 123 if err != nil { 124 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 125 } 126 127 - loggedInUser := s.oauth.GetUser(r) 128 - followStatus := db.IsNotFollowing 129 - if loggedInUser != nil { 130 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 131 - } 132 133 - now := time.Now() 134 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 135 - punchcard, err := db.MakePunchcard( 136 - s.db, 137 - db.FilterEq("did", ident.DID.String()), 138 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 139 - db.FilterLte("date", now.Format(time.DateOnly)), 140 - ) 141 if err != nil { 142 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 143 } 144 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 - LoggedInUser: loggedInUser, 148 - Repos: pinnedRepos, 149 - CollaboratingRepos: pinnedCollaboratingRepos, 150 - DidHandleMap: didHandleMap, 151 - Card: pages.ProfileCard{ 152 - UserDid: ident.DID.String(), 153 - UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 - Profile: profile, 156 - FollowStatus: followStatus, 157 - Followers: followers, 158 - Following: following, 159 - }, 160 - Punchcard: punchcard, 161 - ProfileTimeline: timeline, 162 }) 163 } 164 165 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 166 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 167 if !ok { 168 s.pages.Error404(w) 169 return 170 } 171 172 - profile, err := db.GetProfile(s.db, ident.DID.String()) 173 if err != nil { 174 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 175 } 176 177 - repos, err := db.GetRepos( 178 - s.db, 179 - 0, 180 - db.FilterEq("did", ident.DID.String()), 181 - ) 182 - if err != nil { 183 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 184 } 185 186 - loggedInUser := s.oauth.GetUser(r) 187 - followStatus := db.IsNotFollowing 188 - if loggedInUser != nil { 189 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 190 } 191 192 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 193 if err != nil { 194 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 195 } 196 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 198 199 - s.pages.ReposPage(w, pages.ReposPageParams{ 200 - LoggedInUser: loggedInUser, 201 - Repos: repos, 202 - DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 203 - Card: pages.ProfileCard{ 204 - UserDid: ident.DID.String(), 205 - UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 - Profile: profile, 208 - FollowStatus: followStatus, 209 - Followers: followers, 210 - Following: following, 211 - }, 212 }) 213 } 214 215 - func (s *State) GetAvatarUri(handle string) string { 216 - secret := s.config.Avatar.SharedSecret 217 - h := hmac.New(sha256.New, []byte(secret)) 218 - h.Write([]byte(handle)) 219 - signature := hex.EncodeToString(h.Sum(nil)) 220 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 221 } 222 223 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 395 log.Printf("getting profile data for %s: %s", user.Did, err) 396 } 397 398 - repos, err := db.GetAllReposByDid(s.db, user.Did) 399 if err != nil { 400 log.Printf("getting repos for %s: %s", user.Did, err) 401 } ··· 422 }) 423 } 424 425 - var didsToResolve []string 426 - for _, r := range allRepos { 427 - didsToResolve = append(didsToResolve, r.Did) 428 - } 429 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 430 - didHandleMap := make(map[string]string) 431 - for _, identity := range resolvedIds { 432 - if !identity.Handle.IsInvalidHandle() { 433 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 434 - } else { 435 - didHandleMap[identity.DID.String()] = identity.DID.String() 436 - } 437 - } 438 - 439 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 440 LoggedInUser: user, 441 Profile: profile, 442 AllRepos: allRepos, 443 - DidHandleMap: didHandleMap, 444 }) 445 }
··· 1 package state 2 3 import ( 4 + "context" 5 "fmt" 6 "log" 7 "net/http" ··· 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 + "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" ··· 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 tabVal := r.URL.Query().Get("tab") 25 switch tabVal { 26 case "repos": 27 s.reposPage(w, r) 28 + case "followers": 29 + s.followersPage(w, r) 30 + case "following": 31 + s.followingPage(w, r) 32 + case "starred": 33 + s.starredPage(w, r) 34 + case "strings": 35 + s.stringsPage(w, r) 36 + default: 37 + s.profileOverview(w, r) 38 } 39 } 40 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 42 didOrHandle := chi.URLParam(r, "user") 43 if didOrHandle == "" { 44 + return nil, fmt.Errorf("empty DID or handle") 45 } 46 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 48 if !ok { 49 + return nil, fmt.Errorf("failed to resolve ID") 50 + } 51 + did := ident.DID.String() 52 + 53 + profile, err := db.GetProfile(s.db, did) 54 + if err != nil { 55 + return nil, fmt.Errorf("failed to get profile: %w", err) 56 } 57 58 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 59 if err != nil { 60 + return nil, fmt.Errorf("failed to get repo count: %w", err) 61 + } 62 + 63 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get string count: %w", err) 66 + } 67 + 68 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 + if err != nil { 70 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 71 + } 72 + 73 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 76 + } 77 + 78 + loggedInUser := s.oauth.GetUser(r) 79 + followStatus := db.IsNotFollowing 80 + if loggedInUser != nil { 81 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 + } 83 + 84 + now := time.Now() 85 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 86 + punchcard, err := db.MakePunchcard( 87 + s.db, 88 + db.FilterEq("did", did), 89 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 90 + db.FilterLte("date", now.Format(time.DateOnly)), 91 + ) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 94 } 95 96 + return &pages.ProfileCard{ 97 + UserDid: did, 98 + UserHandle: ident.Handle.String(), 99 + Profile: profile, 100 + FollowStatus: followStatus, 101 + Stats: pages.ProfileStats{ 102 + RepoCount: repoCount, 103 + StringCount: stringCount, 104 + StarredCount: starredCount, 105 + FollowersCount: followStats.Followers, 106 + FollowingCount: followStats.Following, 107 + }, 108 + Punchcard: punchcard, 109 + }, nil 110 + } 111 + 112 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 113 + l := s.logger.With("handler", "profileHomePage") 114 + 115 + profile, err := s.profile(r) 116 + if err != nil { 117 + l.Error("failed to build profile card", "err", err) 118 + s.pages.Error500(w) 119 + return 120 + } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 + 123 repos, err := db.GetRepos( 124 s.db, 125 0, 126 + db.FilterEq("did", profile.UserDid), 127 ) 128 if err != nil { 129 + l.Error("failed to fetch repos", "err", err) 130 } 131 132 // filter out ones that are pinned 133 pinnedRepos := []db.Repo{} 134 for i, r := range repos { 135 // if this is a pinned repo, add it 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 137 pinnedRepos = append(pinnedRepos, r) 138 } 139 140 // if there are no saved pins, add the first 4 repos 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 142 pinnedRepos = append(pinnedRepos, r) 143 } 144 } 145 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 147 if err != nil { 148 + l.Error("failed to fetch collaborating repos", "err", err) 149 } 150 151 pinnedCollaboratingRepos := []db.Repo{} 152 for _, r := range collaboratingRepos { 153 // if this is a pinned repo, add it 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 156 } 157 } 158 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 160 if err != nil { 161 + l.Error("failed to create timeline", "err", err) 162 } 163 164 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 165 + LoggedInUser: s.oauth.GetUser(r), 166 + Card: profile, 167 + Repos: pinnedRepos, 168 + CollaboratingRepos: pinnedCollaboratingRepos, 169 + ProfileTimeline: timeline, 170 + }) 171 + } 172 + 173 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 174 + l := s.logger.With("handler", "reposPage") 175 + 176 + profile, err := s.profile(r) 177 + if err != nil { 178 + l.Error("failed to build profile card", "err", err) 179 + s.pages.Error500(w) 180 + return 181 } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 183 + 184 + repos, err := db.GetRepos( 185 + s.db, 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 188 + ) 189 + if err != nil { 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 193 + } 194 + 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 199 + }) 200 + } 201 + 202 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 203 + l := s.logger.With("handler", "starredPage") 204 + 205 + profile, err := s.profile(r) 206 + if err != nil { 207 + l.Error("failed to build profile card", "err", err) 208 + s.pages.Error500(w) 209 + return 210 + } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 212 + 213 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 214 + if err != nil { 215 + l.Error("failed to get stars", "err", err) 216 + s.pages.Error500(w) 217 + return 218 + } 219 + var repoAts []string 220 + for _, s := range stars { 221 + repoAts = append(repoAts, string(s.RepoAt)) 222 + } 223 + 224 + repos, err := db.GetRepos( 225 + s.db, 226 + 0, 227 + db.FilterIn("at_uri", repoAts), 228 + ) 229 + if err != nil { 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 233 + } 234 + 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 237 + Repos: repos, 238 + Card: profile, 239 + }) 240 + } 241 + 242 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 243 + l := s.logger.With("handler", "stringsPage") 244 + 245 + profile, err := s.profile(r) 246 + if err != nil { 247 + l.Error("failed to build profile card", "err", err) 248 + s.pages.Error500(w) 249 + return 250 + } 251 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 252 + 253 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 254 + if err != nil { 255 + l.Error("failed to get strings", "err", err) 256 + s.pages.Error500(w) 257 + return 258 + } 259 + 260 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 261 + LoggedInUser: s.oauth.GetUser(r), 262 + Strings: strings, 263 + Card: profile, 264 + }) 265 + } 266 + 267 + type FollowsPageParams struct { 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 270 + } 271 + 272 + func (s *State) followPage( 273 + r *http.Request, 274 + fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 + extractDid func(db.Follow) string, 276 + ) (*FollowsPageParams, error) { 277 + l := s.logger.With("handler", "reposPage") 278 + 279 + profile, err := s.profile(r) 280 + if err != nil { 281 + return nil, err 282 + } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 284 + 285 + loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 289 + 290 + follows, err := fetchFollows(s.db, profile.UserDid) 291 + if err != nil { 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 294 + } 295 + 296 + if len(follows) == 0 { 297 + return &params, nil 298 + } 299 + 300 + followDids := make([]string, 0, len(follows)) 301 + for _, follow := range follows { 302 + followDids = append(followDids, extractDid(follow)) 303 + } 304 + 305 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 306 + if err != nil { 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 309 + } 310 + 311 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 312 + if err != nil { 313 + log.Printf("getting follow counts for %s: %s", followDids, err) 314 + } 315 + 316 + loggedInUserFollowing := make(map[string]struct{}) 317 + if loggedInUser != nil { 318 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 + if err != nil { 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 322 } 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 326 } 327 } 328 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 332 + followStatus := db.IsNotFollowing 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 337 + } 338 + 339 + var profile *db.Profile 340 + if p, exists := profiles[did]; exists { 341 + profile = p 342 } else { 343 + profile = &db.Profile{} 344 + profile.Did = did 345 + } 346 + followCards[i] = pages.FollowCard{ 347 + UserDid: did, 348 + FollowStatus: followStatus, 349 + FollowersCount: followStats.Followers, 350 + FollowingCount: followStats.Following, 351 + Profile: profile, 352 } 353 } 354 355 + params.Follows = followCards 356 + 357 + return &params, nil 358 + } 359 + 360 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 362 if err != nil { 363 + s.pages.Notice(w, "all-followers", "Failed to load followers") 364 + return 365 } 366 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 369 + Followers: followPage.Follows, 370 + Card: followPage.Card, 371 + }) 372 + } 373 374 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 376 if err != nil { 377 + s.pages.Notice(w, "all-following", "Failed to load following") 378 + return 379 } 380 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 383 + Following: followPage.Follows, 384 + Card: followPage.Card, 385 }) 386 } 387 388 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 389 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 390 if !ok { 391 s.pages.Error404(w) 392 return 393 } 394 395 + feed, err := s.getProfileFeed(r.Context(), &ident) 396 if err != nil { 397 + s.pages.Error500(w) 398 + return 399 } 400 401 + if feed == nil { 402 + return 403 } 404 405 + atom, err := feed.ToAtom() 406 + if err != nil { 407 + s.pages.Error500(w) 408 + return 409 } 410 411 + w.Header().Set("content-type", "application/atom+xml") 412 + w.Write([]byte(atom)) 413 + } 414 + 415 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 416 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 417 if err != nil { 418 + return nil, err 419 } 420 421 + author := &feeds.Author{ 422 + Name: fmt.Sprintf("@%s", id.Handle), 423 + } 424 425 + feed := feeds.Feed{ 426 + Title: fmt.Sprintf("%s's timeline", author.Name), 427 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 428 + Items: make([]*feeds.Item, 0), 429 + Updated: time.UnixMilli(0), 430 + Author: author, 431 + } 432 + 433 + for _, byMonth := range timeline.ByMonth { 434 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 435 + return nil, err 436 + } 437 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 438 + return nil, err 439 + } 440 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 441 + return nil, err 442 + } 443 + } 444 + 445 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 446 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 447 }) 448 + 449 + if len(feed.Items) > 0 { 450 + feed.Updated = feed.Items[0].Created 451 + } 452 + 453 + return &feed, nil 454 } 455 456 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 457 + for _, pull := range pulls { 458 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 459 + if err != nil { 460 + return err 461 + } 462 + 463 + // Add pull request creation item 464 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 465 + } 466 + return nil 467 + } 468 + 469 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 470 + for _, issue := range issues { 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 + if err != nil { 473 + return err 474 + } 475 + 476 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 477 + } 478 + return nil 479 + } 480 + 481 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 482 + for _, repo := range repos { 483 + item, err := s.createRepoItem(ctx, repo, author) 484 + if err != nil { 485 + return err 486 + } 487 + feed.Items = append(feed.Items, item) 488 + } 489 + return nil 490 + } 491 + 492 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 + return &feeds.Item{ 494 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 495 + 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"}, 496 + Created: pull.Created, 497 + Author: author, 498 + } 499 + } 500 + 501 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 + return &feeds.Item{ 503 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 505 + Created: issue.Created, 506 + Author: author, 507 + } 508 + } 509 + 510 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 511 + var title string 512 + if repo.Source != nil { 513 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 514 + if err != nil { 515 + return nil, err 516 + } 517 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 518 + } else { 519 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 520 + } 521 + 522 + return &feeds.Item{ 523 + Title: title, 524 + 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 525 + Created: repo.Repo.Created, 526 + Author: author, 527 + }, nil 528 } 529 530 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 702 log.Printf("getting profile data for %s: %s", user.Did, err) 703 } 704 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 706 if err != nil { 707 log.Printf("getting repos for %s: %s", user.Did, err) 708 } ··· 729 }) 730 } 731 732 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 733 LoggedInUser: user, 734 Profile: profile, 735 AllRepos: allRepos, 736 }) 737 }
+52 -11
appview/state/router.go
··· 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 "tangled.sh/tangled.sh/core/appview/repo" 16 "tangled.sh/tangled.sh/core/appview/settings" 17 "tangled.sh/tangled.sh/core/appview/spindles" 18 "tangled.sh/tangled.sh/core/appview/state/userutil" 19 "tangled.sh/tangled.sh/core/log" 20 ) 21 ··· 29 s.idResolver, 30 s.pages, 31 ) 32 33 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 34 pat := chi.URLParam(r, "*") 35 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 36 - s.UserRouter(&middleware).ServeHTTP(w, r) 37 } else { 38 // Check if the first path element is a valid handle without '@' or a flattened DID 39 pathParts := strings.SplitN(pat, "/", 2) ··· 56 return 57 } 58 } 59 - s.StandardRouter(&middleware).ServeHTTP(w, r) 60 } 61 }) 62 ··· 66 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 67 r := chi.NewRouter() 68 69 - // strip @ from user 70 - r.Use(middleware.StripLeadingAt) 71 - 72 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 73 r.Get("/", s.Profile) 74 75 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 76 r.Use(mw.GoImport()) 77 - 78 r.Mount("/", s.RepoRouter(mw)) 79 r.Mount("/issues", s.IssuesRouter(mw)) 80 r.Mount("/pulls", s.PullsRouter(mw)) ··· 100 101 r.Handle("/static/*", s.pages.Static()) 102 103 - r.Get("/", s.Timeline) 104 105 r.Route("/repo", func(r chi.Router) { 106 r.Route("/new", func(r chi.Router) { ··· 135 }) 136 137 r.Mount("/settings", s.SettingsRouter()) 138 - r.Mount("/knots", s.KnotsRouter(mw)) 139 r.Mount("/spindles", s.SpindlesRouter()) 140 r.Mount("/", s.OAuthRouter()) 141 142 r.Get("/keys/{user}", s.Keys) 143 144 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 145 s.pages.Error404(w) ··· 180 return spindles.Router() 181 } 182 183 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 184 logger := log.New("knots") 185 186 knots := &knots.Knots{ ··· 194 Logger: logger, 195 } 196 197 - return knots.Router(mw) 198 } 199 200 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 201 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 202 return issues.Router(mw) 203 } 204 ··· 217 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 218 return pipes.Router(mw) 219 }
··· 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 "tangled.sh/tangled.sh/core/appview/repo" 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 21 "tangled.sh/tangled.sh/core/log" 22 ) 23 ··· 31 s.idResolver, 32 s.pages, 33 ) 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 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 42 pat := chi.URLParam(r, "*") 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 44 + userRouter.ServeHTTP(w, r) 45 } else { 46 // Check if the first path element is a valid handle without '@' or a flattened DID 47 pathParts := strings.SplitN(pat, "/", 2) ··· 64 return 65 } 66 } 67 + standardRouter.ServeHTTP(w, r) 68 } 69 }) 70 ··· 74 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 75 r := chi.NewRouter() 76 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 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 + }) 86 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 88 r.Use(mw.GoImport()) 89 r.Mount("/", s.RepoRouter(mw)) 90 r.Mount("/issues", s.IssuesRouter(mw)) 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 111 112 r.Handle("/static/*", s.pages.Static()) 113 114 + r.Get("/", s.HomeOrTimeline) 115 + r.Get("/timeline", s.Timeline) 116 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 117 118 r.Route("/repo", func(r chi.Router) { 119 r.Route("/new", func(r chi.Router) { ··· 148 }) 149 150 r.Mount("/settings", s.SettingsRouter()) 151 + r.Mount("/strings", s.StringsRouter(mw)) 152 + r.Mount("/knots", s.KnotsRouter()) 153 r.Mount("/spindles", s.SpindlesRouter()) 154 + r.Mount("/signup", s.SignupRouter()) 155 r.Mount("/", s.OAuthRouter()) 156 157 r.Get("/keys/{user}", s.Keys) 158 + r.Get("/terms", s.TermsOfService) 159 + r.Get("/privacy", s.PrivacyPolicy) 160 161 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 162 s.pages.Error404(w) ··· 197 return spindles.Router() 198 } 199 200 + func (s *State) KnotsRouter() http.Handler { 201 logger := log.New("knots") 202 203 knots := &knots.Knots{ ··· 211 Logger: logger, 212 } 213 214 + return knots.Router() 215 + } 216 + 217 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 218 + logger := log.New("strings") 219 + 220 + strs := &avstrings.Strings{ 221 + Db: s.db, 222 + OAuth: s.oauth, 223 + Pages: s.pages, 224 + Config: s.config, 225 + Enforcer: s.enforcer, 226 + IdResolver: s.idResolver, 227 + Knotstream: s.knotstream, 228 + Logger: logger, 229 + } 230 + 231 + return strs.Router(mw) 232 } 233 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 236 return issues.Router(mw) 237 } 238 ··· 251 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 252 return pipes.Router(mw) 253 } 254 + 255 + func (s *State) SignupRouter() http.Handler { 256 + logger := log.New("signup") 257 + 258 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 259 + return sig.Router() 260 + }
+214 -72
appview/state/state.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "log/slog" ··· 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/go-chi/chi/v5" ··· 23 "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 - posthog_service "tangled.sh/tangled.sh/core/appview/posthog" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 "tangled.sh/tangled.sh/core/idresolver" 30 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/tid" 35 ) 36 37 type State struct { ··· 48 repoResolver *reporesolver.RepoResolver 49 knotstream *eventconsumer.Consumer 50 spindlestream *eventconsumer.Consumer 51 } 52 53 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 } 63 64 - pgs := pages.NewPages(config) 65 - 66 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 if err != nil { 68 log.Printf("failed to create redis resolver: %v", err) 69 res = idresolver.DefaultResolver() 70 } 71 72 cache := cache.New(config.Redis.Addr) 73 sess := session.New(cache) 74 - 75 oauth := oauth.NewOAuth(config, sess) 76 77 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 78 if err != nil { ··· 93 tangled.ActorProfileNSID, 94 tangled.SpindleMemberNSID, 95 tangled.SpindleNSID, 96 }, 97 nil, 98 slog.Default(), ··· 113 IdResolver: res, 114 Config: config, 115 Logger: tlog.New("ingester"), 116 } 117 err = jc.StartJetstream(ctx, ingester.Ingest()) 118 if err != nil { ··· 133 134 var notifiers []notify.Notifier 135 if !config.Core.Dev { 136 - notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog)) 137 } 138 notifier := notify.NewMergedNotifier(notifiers...) 139 ··· 151 repoResolver, 152 knotstream, 153 spindlestream, 154 } 155 156 return state, nil 157 } 158 159 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 160 user := s.oauth.GetUser(r) 161 162 - timeline, err := db.MakeTimeline(s.db) 163 if err != nil { 164 log.Println(err) 165 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 166 } 167 168 - var didsToResolve []string 169 - for _, ev := range timeline { 170 - if ev.Repo != nil { 171 - didsToResolve = append(didsToResolve, ev.Repo.Did) 172 - if ev.Source != nil { 173 - didsToResolve = append(didsToResolve, ev.Source.Did) 174 - } 175 - } 176 - if ev.Follow != nil { 177 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 178 - } 179 - if ev.Star != nil { 180 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 181 - } 182 - } 183 - 184 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 185 - didHandleMap := make(map[string]string) 186 - for _, identity := range resolvedIds { 187 - if !identity.Handle.IsInvalidHandle() { 188 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 189 - } else { 190 - didHandleMap[identity.DID.String()] = identity.DID.String() 191 - } 192 } 193 194 s.pages.Timeline(w, pages.TimelineParams{ 195 LoggedInUser: user, 196 Timeline: timeline, 197 - DidHandleMap: didHandleMap, 198 }) 199 200 - return 201 } 202 203 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 228 229 for _, k := range pubKeys { 230 key := strings.TrimRight(k.Key, "\n") 231 - w.Write([]byte(fmt.Sprintln(key))) 232 } 233 } 234 ··· 264 return nil 265 } 266 267 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 268 switch r.Method { 269 case http.MethodGet: ··· 280 }) 281 282 case http.MethodPost: 283 user := s.oauth.GetUser(r) 284 285 domain := r.FormValue("domain") 286 if domain == "" { 287 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 288 return 289 } 290 291 repoName := r.FormValue("name") 292 if repoName == "" { ··· 298 s.pages.Notice(w, "repo", err.Error()) 299 return 300 } 301 302 defaultBranch := r.FormValue("branch") 303 if defaultBranch == "" { 304 defaultBranch = "main" 305 } 306 307 description := r.FormValue("description") 308 309 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 310 if err != nil || !ok { 311 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 312 return 313 } 314 315 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 316 if err == nil && existingRepo != nil { 317 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 318 - return 319 - } 320 - 321 - secret, err := db.GetRegistrationKey(s.db, domain) 322 - if err != nil { 323 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 324 - return 325 - } 326 - 327 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 328 - if err != nil { 329 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 330 return 331 } 332 333 rkey := tid.TID() 334 repo := &db.Repo{ 335 Did: user.Did, ··· 341 342 xrpcClient, err := s.oauth.AuthorizedClient(r) 343 if err != nil { 344 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 345 return 346 } ··· 359 }}, 360 }) 361 if err != nil { 362 - log.Printf("failed to create record: %s", err) 363 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 364 return 365 } 366 - log.Println("created repo record: ", atresp.Uri) 367 368 tx, err := s.db.BeginTx(r.Context(), nil) 369 if err != nil { 370 - log.Println(err) 371 s.pages.Notice(w, "repo", "Failed to save repository information.") 372 return 373 } 374 - defer func() { 375 - tx.Rollback() 376 - err = s.enforcer.E.LoadPolicy() 377 - if err != nil { 378 - log.Println("failed to rollback policies") 379 } 380 - }() 381 382 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 383 if err != nil { 384 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 385 return 386 } 387 388 - switch resp.StatusCode { 389 - case http.StatusConflict: 390 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 391 return 392 - case http.StatusInternalServerError: 393 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 394 - case http.StatusNoContent: 395 - // continue 396 } 397 398 - repo.AtUri = atresp.Uri 399 err = db.AddRepo(tx, repo) 400 if err != nil { 401 - log.Println(err) 402 s.pages.Notice(w, "repo", "Failed to save repository information.") 403 return 404 } ··· 407 p, _ := securejoin.SecureJoin(user.Did, repoName) 408 err = s.enforcer.AddRepo(user.Did, domain, p) 409 if err != nil { 410 - log.Println(err) 411 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 412 return 413 } 414 415 err = tx.Commit() 416 if err != nil { 417 - log.Println("failed to commit changes", err) 418 http.Error(w, err.Error(), http.StatusInternalServerError) 419 return 420 } 421 422 err = s.enforcer.E.SavePolicy() 423 if err != nil { 424 - log.Println("failed to update ACLs", err) 425 http.Error(w, err.Error(), http.StatusInternalServerError) 426 return 427 } 428 429 s.notifier.NewRepo(r.Context(), repo) 430 431 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 432 - return 433 } 434 }
··· 2 3 import ( 4 "context" 5 + "database/sql" 6 + "errors" 7 "fmt" 8 "log" 9 "log/slog" ··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "github.com/go-chi/chi/v5" ··· 26 "tangled.sh/tangled.sh/core/appview/notify" 27 "tangled.sh/tangled.sh/core/appview/oauth" 28 "tangled.sh/tangled.sh/core/appview/pages" 29 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 32 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 "tangled.sh/tangled.sh/core/idresolver" 35 "tangled.sh/tangled.sh/core/jetstream" 36 tlog "tangled.sh/tangled.sh/core/log" 37 "tangled.sh/tangled.sh/core/rbac" 38 "tangled.sh/tangled.sh/core/tid" 39 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 40 ) 41 42 type State struct { ··· 53 repoResolver *reporesolver.RepoResolver 54 knotstream *eventconsumer.Consumer 55 spindlestream *eventconsumer.Consumer 56 + logger *slog.Logger 57 + validator *validator.Validator 58 } 59 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 68 return nil, fmt.Errorf("failed to create enforcer: %w", err) 69 } 70 71 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 72 if err != nil { 73 log.Printf("failed to create redis resolver: %v", err) 74 res = idresolver.DefaultResolver() 75 } 76 77 + pgs := pages.NewPages(config, res) 78 cache := cache.New(config.Redis.Addr) 79 sess := session.New(cache) 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 if err != nil { ··· 99 tangled.ActorProfileNSID, 100 tangled.SpindleMemberNSID, 101 tangled.SpindleNSID, 102 + tangled.StringNSID, 103 + tangled.RepoIssueNSID, 104 + tangled.RepoIssueCommentNSID, 105 }, 106 nil, 107 slog.Default(), ··· 122 IdResolver: res, 123 Config: config, 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 126 } 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 128 if err != nil { ··· 143 144 var notifiers []notify.Notifier 145 if !config.Core.Dev { 146 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 147 } 148 notifier := notify.NewMergedNotifier(notifiers...) 149 ··· 161 repoResolver, 162 knotstream, 163 spindlestream, 164 + slog.Default(), 165 + validator, 166 } 167 168 return state, nil 169 } 170 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 174 + } 175 + 176 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 177 + w.Header().Set("Content-Type", "image/svg+xml") 178 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 179 + w.Header().Set("ETag", `"favicon-svg-v1"`) 180 + 181 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 182 + w.WriteHeader(http.StatusNotModified) 183 + return 184 + } 185 + 186 + s.pages.Favicon(w) 187 + } 188 + 189 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 190 + user := s.oauth.GetUser(r) 191 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 192 + LoggedInUser: user, 193 + }) 194 + } 195 + 196 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 197 + user := s.oauth.GetUser(r) 198 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 199 + LoggedInUser: user, 200 + }) 201 + } 202 + 203 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 + if s.oauth.GetUser(r) != nil { 205 + s.Timeline(w, r) 206 + return 207 + } 208 + s.Home(w, r) 209 + } 210 + 211 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 212 user := s.oauth.GetUser(r) 213 214 + timeline, err := db.MakeTimeline(s.db, 50) 215 if err != nil { 216 log.Println(err) 217 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 218 } 219 220 + repos, err := db.GetTopStarredReposLastWeek(s.db) 221 + if err != nil { 222 + log.Println(err) 223 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 224 + return 225 } 226 227 s.pages.Timeline(w, pages.TimelineParams{ 228 LoggedInUser: user, 229 Timeline: timeline, 230 + Repos: repos, 231 }) 232 + } 233 234 + func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 235 + user := s.oauth.GetUser(r) 236 + l := s.logger.With("handler", "UpgradeBanner") 237 + l = l.With("did", user.Did) 238 + l = l.With("handle", user.Handle) 239 + 240 + regs, err := db.GetRegistrations( 241 + s.db, 242 + db.FilterEq("did", user.Did), 243 + db.FilterEq("needs_upgrade", 1), 244 + ) 245 + if err != nil { 246 + l.Error("non-fatal: failed to get registrations", "err", err) 247 + } 248 + 249 + spindles, err := db.GetSpindles( 250 + s.db, 251 + db.FilterEq("owner", user.Did), 252 + db.FilterEq("needs_upgrade", 1), 253 + ) 254 + if err != nil { 255 + l.Error("non-fatal: failed to get spindles", "err", err) 256 + } 257 + 258 + if regs == nil && spindles == nil { 259 + return 260 + } 261 + 262 + s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 263 + Registrations: regs, 264 + Spindles: spindles, 265 + }) 266 + } 267 + 268 + func (s *State) Home(w http.ResponseWriter, r *http.Request) { 269 + timeline, err := db.MakeTimeline(s.db, 5) 270 + if err != nil { 271 + log.Println(err) 272 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 273 + return 274 + } 275 + 276 + repos, err := db.GetTopStarredReposLastWeek(s.db) 277 + if err != nil { 278 + log.Println(err) 279 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 280 + return 281 + } 282 + 283 + s.pages.Home(w, pages.TimelineParams{ 284 + LoggedInUser: nil, 285 + Timeline: timeline, 286 + Repos: repos, 287 + }) 288 } 289 290 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 315 316 for _, k := range pubKeys { 317 key := strings.TrimRight(k.Key, "\n") 318 + fmt.Fprintln(w, key) 319 } 320 } 321 ··· 351 return nil 352 } 353 354 + func stripGitExt(name string) string { 355 + return strings.TrimSuffix(name, ".git") 356 + } 357 + 358 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 359 switch r.Method { 360 case http.MethodGet: ··· 371 }) 372 373 case http.MethodPost: 374 + l := s.logger.With("handler", "NewRepo") 375 + 376 user := s.oauth.GetUser(r) 377 + l = l.With("did", user.Did) 378 + l = l.With("handle", user.Handle) 379 380 + // form validation 381 domain := r.FormValue("domain") 382 if domain == "" { 383 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 384 return 385 } 386 + l = l.With("knot", domain) 387 388 repoName := r.FormValue("name") 389 if repoName == "" { ··· 395 s.pages.Notice(w, "repo", err.Error()) 396 return 397 } 398 + repoName = stripGitExt(repoName) 399 + l = l.With("repoName", repoName) 400 401 defaultBranch := r.FormValue("branch") 402 if defaultBranch == "" { 403 defaultBranch = "main" 404 } 405 + l = l.With("defaultBranch", defaultBranch) 406 407 description := r.FormValue("description") 408 409 + // ACL validation 410 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 411 if err != nil || !ok { 412 + l.Info("unauthorized") 413 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 414 return 415 } 416 417 + // Check for existing repos 418 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 419 if err == nil && existingRepo != nil { 420 + l.Info("repo exists") 421 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 422 return 423 } 424 425 + // create atproto record for this repo 426 rkey := tid.TID() 427 repo := &db.Repo{ 428 Did: user.Did, ··· 434 435 xrpcClient, err := s.oauth.AuthorizedClient(r) 436 if err != nil { 437 + l.Info("PDS write failed", "err", err) 438 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 439 return 440 } ··· 453 }}, 454 }) 455 if err != nil { 456 + l.Info("PDS write failed", "err", err) 457 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 458 return 459 } 460 + 461 + aturi := atresp.Uri 462 + l = l.With("aturi", aturi) 463 + l.Info("wrote to PDS") 464 465 tx, err := s.db.BeginTx(r.Context(), nil) 466 if err != nil { 467 + l.Info("txn failed", "err", err) 468 s.pages.Notice(w, "repo", "Failed to save repository information.") 469 return 470 } 471 + 472 + // The rollback function reverts a few things on failure: 473 + // - the pending txn 474 + // - the ACLs 475 + // - the atproto record created 476 + rollback := func() { 477 + err1 := tx.Rollback() 478 + err2 := s.enforcer.E.LoadPolicy() 479 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 480 + 481 + // ignore txn complete errors, this is okay 482 + if errors.Is(err1, sql.ErrTxDone) { 483 + err1 = nil 484 + } 485 + 486 + if errs := errors.Join(err1, err2, err3); errs != nil { 487 + l.Error("failed to rollback changes", "errs", errs) 488 + return 489 } 490 + } 491 + defer rollback() 492 493 + client, err := s.oauth.ServiceClient( 494 + r, 495 + oauth.WithService(domain), 496 + oauth.WithLxm(tangled.RepoCreateNSID), 497 + oauth.WithDev(s.config.Core.Dev), 498 + ) 499 if err != nil { 500 + l.Error("service auth failed", "err", err) 501 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 502 return 503 } 504 505 + xe := tangled.RepoCreate( 506 + r.Context(), 507 + client, 508 + &tangled.RepoCreate_Input{ 509 + Rkey: rkey, 510 + }, 511 + ) 512 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 513 + l.Error("xrpc error", "xe", xe) 514 + s.pages.Notice(w, "repo", err.Error()) 515 return 516 } 517 518 err = db.AddRepo(tx, repo) 519 if err != nil { 520 + l.Error("db write failed", "err", err) 521 s.pages.Notice(w, "repo", "Failed to save repository information.") 522 return 523 } ··· 526 p, _ := securejoin.SecureJoin(user.Did, repoName) 527 err = s.enforcer.AddRepo(user.Did, domain, p) 528 if err != nil { 529 + l.Error("acl setup failed", "err", err) 530 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 531 return 532 } 533 534 err = tx.Commit() 535 if err != nil { 536 + l.Error("txn commit failed", "err", err) 537 http.Error(w, err.Error(), http.StatusInternalServerError) 538 return 539 } 540 541 err = s.enforcer.E.SavePolicy() 542 if err != nil { 543 + l.Error("acl save failed", "err", err) 544 http.Error(w, err.Error(), http.StatusInternalServerError) 545 return 546 } 547 548 + // reset the ATURI because the transaction completed successfully 549 + aturi = "" 550 + 551 s.notifier.NewRepo(r.Context(), repo) 552 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 553 + } 554 + } 555 556 + // this is used to rollback changes made to the PDS 557 + // 558 + // it is a no-op if the provided ATURI is empty 559 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 560 + if aturi == "" { 561 + return nil 562 } 563 + 564 + parsed := syntax.ATURI(aturi) 565 + 566 + collection := parsed.Collection().String() 567 + repo := parsed.Authority().String() 568 + rkey := parsed.RecordKey().String() 569 + 570 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 571 + Collection: collection, 572 + Repo: repo, 573 + Rkey: rkey, 574 + }) 575 + return err 576 }
+6
appview/state/userutil/userutil.go
··· 51 func IsDid(s string) bool { 52 return didRegex.MatchString(s) 53 }
··· 51 func IsDid(s string) bool { 52 return didRegex.MatchString(s) 53 } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 + }
+415
appview/strings/strings.go
···
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "strconv" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview/config" 13 + "tangled.sh/tangled.sh/core/appview/db" 14 + "tangled.sh/tangled.sh/core/appview/middleware" 15 + "tangled.sh/tangled.sh/core/appview/notify" 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 + Notifier notify.Notifier 41 + } 42 + 43 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 44 + r := chi.NewRouter() 45 + 46 + r. 47 + Get("/", s.timeline) 48 + 49 + r. 50 + With(mw.ResolveIdent()). 51 + Route("/{user}", func(r chi.Router) { 52 + r.Get("/", s.dashboard) 53 + 54 + r.Route("/{rkey}", func(r chi.Router) { 55 + r.Get("/", s.contents) 56 + r.Delete("/", s.delete) 57 + r.Get("/raw", s.contents) 58 + r.Get("/edit", s.edit) 59 + r.Post("/edit", s.edit) 60 + r. 61 + With(middleware.AuthMiddleware(s.OAuth)). 62 + Post("/comment", s.comment) 63 + }) 64 + }) 65 + 66 + r. 67 + With(middleware.AuthMiddleware(s.OAuth)). 68 + Route("/new", func(r chi.Router) { 69 + r.Get("/", s.create) 70 + r.Post("/", s.create) 71 + }) 72 + 73 + return r 74 + } 75 + 76 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 77 + l := s.Logger.With("handler", "timeline") 78 + 79 + strings, err := db.GetStrings(s.Db, 50) 80 + if err != nil { 81 + l.Error("failed to fetch string", "err", err) 82 + w.WriteHeader(http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 87 + LoggedInUser: s.OAuth.GetUser(r), 88 + Strings: strings, 89 + }) 90 + } 91 + 92 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 93 + l := s.Logger.With("handler", "contents") 94 + 95 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 96 + if !ok { 97 + l.Error("malformed middleware") 98 + w.WriteHeader(http.StatusInternalServerError) 99 + return 100 + } 101 + l = l.With("did", id.DID, "handle", id.Handle) 102 + 103 + rkey := chi.URLParam(r, "rkey") 104 + if rkey == "" { 105 + l.Error("malformed url, empty rkey") 106 + w.WriteHeader(http.StatusBadRequest) 107 + return 108 + } 109 + l = l.With("rkey", rkey) 110 + 111 + strings, err := db.GetStrings( 112 + s.Db, 113 + 0, 114 + db.FilterEq("did", id.DID), 115 + db.FilterEq("rkey", rkey), 116 + ) 117 + if err != nil { 118 + l.Error("failed to fetch string", "err", err) 119 + w.WriteHeader(http.StatusInternalServerError) 120 + return 121 + } 122 + if len(strings) < 1 { 123 + l.Error("string not found") 124 + s.Pages.Error404(w) 125 + return 126 + } 127 + if len(strings) != 1 { 128 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 129 + w.WriteHeader(http.StatusInternalServerError) 130 + return 131 + } 132 + string := strings[0] 133 + 134 + if path.Base(r.URL.Path) == "raw" { 135 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 136 + if string.Filename != "" { 137 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 138 + } 139 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 140 + 141 + _, err = w.Write([]byte(string.Contents)) 142 + if err != nil { 143 + l.Error("failed to write raw response", "err", err) 144 + } 145 + return 146 + } 147 + 148 + var showRendered, renderToggle bool 149 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 150 + renderToggle = true 151 + showRendered = r.URL.Query().Get("code") != "true" 152 + } 153 + 154 + s.Pages.SingleString(w, pages.SingleStringParams{ 155 + LoggedInUser: s.OAuth.GetUser(r), 156 + RenderToggle: renderToggle, 157 + ShowRendered: showRendered, 158 + String: string, 159 + Stats: string.Stats(), 160 + Owner: id, 161 + }) 162 + } 163 + 164 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 165 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 166 + } 167 + 168 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 169 + l := s.Logger.With("handler", "edit") 170 + 171 + user := s.OAuth.GetUser(r) 172 + 173 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 174 + if !ok { 175 + l.Error("malformed middleware") 176 + w.WriteHeader(http.StatusInternalServerError) 177 + return 178 + } 179 + l = l.With("did", id.DID, "handle", id.Handle) 180 + 181 + rkey := chi.URLParam(r, "rkey") 182 + if rkey == "" { 183 + l.Error("malformed url, empty rkey") 184 + w.WriteHeader(http.StatusBadRequest) 185 + return 186 + } 187 + l = l.With("rkey", rkey) 188 + 189 + // get the string currently being edited 190 + all, err := db.GetStrings( 191 + s.Db, 192 + 0, 193 + db.FilterEq("did", id.DID), 194 + db.FilterEq("rkey", rkey), 195 + ) 196 + if err != nil { 197 + l.Error("failed to fetch string", "err", err) 198 + w.WriteHeader(http.StatusInternalServerError) 199 + return 200 + } 201 + if len(all) != 1 { 202 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 203 + w.WriteHeader(http.StatusInternalServerError) 204 + return 205 + } 206 + first := all[0] 207 + 208 + // verify that the logged in user owns this string 209 + if user.Did != id.DID.String() { 210 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 211 + w.WriteHeader(http.StatusUnauthorized) 212 + return 213 + } 214 + 215 + switch r.Method { 216 + case http.MethodGet: 217 + // return the form with prefilled fields 218 + s.Pages.PutString(w, pages.PutStringParams{ 219 + LoggedInUser: s.OAuth.GetUser(r), 220 + Action: "edit", 221 + String: first, 222 + }) 223 + case http.MethodPost: 224 + fail := func(msg string, err error) { 225 + l.Error(msg, "err", err) 226 + s.Pages.Notice(w, "error", msg) 227 + } 228 + 229 + filename := r.FormValue("filename") 230 + if filename == "" { 231 + fail("Empty filename.", nil) 232 + return 233 + } 234 + 235 + content := r.FormValue("content") 236 + if content == "" { 237 + fail("Empty contents.", nil) 238 + return 239 + } 240 + 241 + description := r.FormValue("description") 242 + 243 + // construct new string from form values 244 + entry := db.String{ 245 + Did: first.Did, 246 + Rkey: first.Rkey, 247 + Filename: filename, 248 + Description: description, 249 + Contents: content, 250 + Created: first.Created, 251 + } 252 + 253 + record := entry.AsRecord() 254 + 255 + client, err := s.OAuth.AuthorizedClient(r) 256 + if err != nil { 257 + fail("Failed to create record.", err) 258 + return 259 + } 260 + 261 + // first replace the existing record in the PDS 262 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 263 + if err != nil { 264 + fail("Failed to updated existing record.", err) 265 + return 266 + } 267 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 268 + Collection: tangled.StringNSID, 269 + Repo: entry.Did.String(), 270 + Rkey: entry.Rkey, 271 + SwapRecord: ex.Cid, 272 + Record: &lexutil.LexiconTypeDecoder{ 273 + Val: &record, 274 + }, 275 + }) 276 + if err != nil { 277 + fail("Failed to updated existing record.", err) 278 + return 279 + } 280 + l := l.With("aturi", resp.Uri) 281 + l.Info("edited string") 282 + 283 + // if that went okay, updated the db 284 + if err = db.AddString(s.Db, entry); err != nil { 285 + fail("Failed to update string.", err) 286 + return 287 + } 288 + 289 + s.Notifier.EditString(r.Context(), &entry) 290 + 291 + // if that went okay, redir to the string 292 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 293 + } 294 + 295 + } 296 + 297 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 298 + l := s.Logger.With("handler", "create") 299 + user := s.OAuth.GetUser(r) 300 + 301 + switch r.Method { 302 + case http.MethodGet: 303 + s.Pages.PutString(w, pages.PutStringParams{ 304 + LoggedInUser: s.OAuth.GetUser(r), 305 + Action: "new", 306 + }) 307 + case http.MethodPost: 308 + fail := func(msg string, err error) { 309 + l.Error(msg, "err", err) 310 + s.Pages.Notice(w, "error", msg) 311 + } 312 + 313 + filename := r.FormValue("filename") 314 + if filename == "" { 315 + fail("Empty filename.", nil) 316 + return 317 + } 318 + 319 + content := r.FormValue("content") 320 + if content == "" { 321 + fail("Empty contents.", nil) 322 + return 323 + } 324 + 325 + description := r.FormValue("description") 326 + 327 + string := db.String{ 328 + Did: syntax.DID(user.Did), 329 + Rkey: tid.TID(), 330 + Filename: filename, 331 + Description: description, 332 + Contents: content, 333 + Created: time.Now(), 334 + } 335 + 336 + record := string.AsRecord() 337 + 338 + client, err := s.OAuth.AuthorizedClient(r) 339 + if err != nil { 340 + fail("Failed to create record.", err) 341 + return 342 + } 343 + 344 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 345 + Collection: tangled.StringNSID, 346 + Repo: user.Did, 347 + Rkey: string.Rkey, 348 + Record: &lexutil.LexiconTypeDecoder{ 349 + Val: &record, 350 + }, 351 + }) 352 + if err != nil { 353 + fail("Failed to create record.", err) 354 + return 355 + } 356 + l := l.With("aturi", resp.Uri) 357 + l.Info("created record") 358 + 359 + // insert into DB 360 + if err = db.AddString(s.Db, string); err != nil { 361 + fail("Failed to create string.", err) 362 + return 363 + } 364 + 365 + s.Notifier.NewString(r.Context(), &string) 366 + 367 + // successful 368 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 369 + } 370 + } 371 + 372 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 373 + l := s.Logger.With("handler", "create") 374 + user := s.OAuth.GetUser(r) 375 + fail := func(msg string, err error) { 376 + l.Error(msg, "err", err) 377 + s.Pages.Notice(w, "error", msg) 378 + } 379 + 380 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 381 + if !ok { 382 + l.Error("malformed middleware") 383 + w.WriteHeader(http.StatusInternalServerError) 384 + return 385 + } 386 + l = l.With("did", id.DID, "handle", id.Handle) 387 + 388 + rkey := chi.URLParam(r, "rkey") 389 + if rkey == "" { 390 + l.Error("malformed url, empty rkey") 391 + w.WriteHeader(http.StatusBadRequest) 392 + return 393 + } 394 + 395 + if user.Did != id.DID.String() { 396 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 397 + return 398 + } 399 + 400 + if err := db.DeleteString( 401 + s.Db, 402 + db.FilterEq("did", user.Did), 403 + db.FilterEq("rkey", rkey), 404 + ); err != nil { 405 + fail("Failed to delete string.", err) 406 + return 407 + } 408 + 409 + s.Notifier.DeleteString(r.Context(), user.Did, rkey) 410 + 411 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 412 + } 413 + 414 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 415 + }
+53
appview/validator/issue.go
···
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.sh/tangled.sh/core/appview/db" 8 + ) 9 + 10 + func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + // if comments have parents, only ingest ones that are 1 level deep 12 + if comment.ReplyTo != nil { 13 + parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 14 + if err != nil { 15 + return fmt.Errorf("failed to fetch parent comment: %w", err) 16 + } 17 + if len(parents) != 1 { 18 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 19 + } 20 + 21 + // depth check 22 + parent := parents[0] 23 + if parent.ReplyTo != nil { 24 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 25 + } 26 + } 27 + 28 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 29 + return fmt.Errorf("body is empty after HTML sanitization") 30 + } 31 + 32 + return nil 33 + } 34 + 35 + func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + if issue.Title == "" { 37 + return fmt.Errorf("issue title is empty") 38 + } 39 + 40 + if issue.Body == "" { 41 + return fmt.Errorf("issue body is empty") 42 + } 43 + 44 + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 45 + return fmt.Errorf("title is empty after HTML sanitization") 46 + } 47 + 48 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 49 + return fmt.Errorf("body is empty after HTML sanitization") 50 + } 51 + 52 + return nil 53 + }
+18
appview/validator/validator.go
···
··· 1 + package validator 2 + 3 + import ( 4 + "tangled.sh/tangled.sh/core/appview/db" 5 + "tangled.sh/tangled.sh/core/appview/pages/markup" 6 + ) 7 + 8 + type Validator struct { 9 + db *db.DB 10 + sanitizer markup.Sanitizer 11 + } 12 + 13 + func New(db *db.DB) *Validator { 14 + return &Validator{ 15 + db: db, 16 + sanitizer: markup.NewSanitizer(), 17 + } 18 + }
+31
appview/xrpcclient/xrpc.go
··· 3 import ( 4 "bytes" 5 "context" 6 "io" 7 8 "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/xrpc" 10 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 ) 12 13 type Client struct { ··· 102 103 return &out, nil 104 }
··· 3 import ( 4 "bytes" 5 "context" 6 + "errors" 7 "io" 8 + "net/http" 9 10 "github.com/bluesky-social/indigo/api/atproto" 11 "github.com/bluesky-social/indigo/xrpc" 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 + ) 15 + 16 + var ( 17 + ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") 18 + ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") 19 + ErrXrpcFailed = errors.New("xrpc request failed") 20 + ErrXrpcInvalid = errors.New("invalid xrpc request") 21 ) 22 23 type Client struct { ··· 112 113 return &out, nil 114 } 115 + 116 + // produces a more manageable error 117 + func HandleXrpcErr(err error) error { 118 + if err == nil { 119 + return nil 120 + } 121 + 122 + var xrpcerr *indigoxrpc.Error 123 + if ok := errors.As(err, &xrpcerr); !ok { 124 + return ErrXrpcInvalid 125 + } 126 + 127 + switch xrpcerr.StatusCode { 128 + case http.StatusNotFound: 129 + return ErrXrpcUnsupported 130 + case http.StatusUnauthorized: 131 + return ErrXrpcUnauthorized 132 + default: 133 + return ErrXrpcFailed 134 + } 135 + }
+3
cmd/appview/main.go
··· 23 } 24 25 state, err := state.Make(ctx, c) 26 27 if err != nil { 28 log.Fatal(err)
··· 23 } 24 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 29 30 if err != nil { 31 log.Fatal(err)
+8 -6
cmd/gen.go
··· 18 tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{}, 21 tangled.GitRefUpdate_Meta{}, 22 - tangled.GitRefUpdate_Meta_CommitCount{}, 23 - tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 24 - tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 - tangled.GitRefUpdate_Pair{}, 26 tangled.GraphFollow{}, 27 tangled.KnotMember{}, 28 tangled.Pipeline{}, 29 tangled.Pipeline_CloneOpts{}, 30 - tangled.Pipeline_Dependency{}, 31 tangled.Pipeline_ManualTriggerData{}, 32 tangled.Pipeline_Pair{}, 33 tangled.Pipeline_PullRequestTriggerData{}, 34 tangled.Pipeline_PushTriggerData{}, 35 tangled.PipelineStatus{}, 36 - tangled.Pipeline_Step{}, 37 tangled.Pipeline_TriggerMetadata{}, 38 tangled.Pipeline_TriggerRepo{}, 39 tangled.Pipeline_Workflow{}, 40 tangled.PublicKey{}, 41 tangled.Repo{}, 42 tangled.RepoArtifact{}, 43 tangled.RepoIssue{}, 44 tangled.RepoIssueComment{}, 45 tangled.RepoIssueState{}, ··· 47 tangled.RepoPullComment{}, 48 tangled.RepoPull_Source{}, 49 tangled.RepoPullStatus{}, 50 tangled.Spindle{}, 51 tangled.SpindleMember{}, 52 ); err != nil { 53 panic(err) 54 }
··· 18 tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_LangBreakdown{}, 24 + tangled.GitRefUpdate_IndividualLanguageSize{}, 25 tangled.GitRefUpdate_Meta{}, 26 tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 tangled.KnotMember{}, 29 tangled.Pipeline{}, 30 tangled.Pipeline_CloneOpts{}, 31 tangled.Pipeline_ManualTriggerData{}, 32 tangled.Pipeline_Pair{}, 33 tangled.Pipeline_PullRequestTriggerData{}, 34 tangled.Pipeline_PushTriggerData{}, 35 tangled.PipelineStatus{}, 36 tangled.Pipeline_TriggerMetadata{}, 37 tangled.Pipeline_TriggerRepo{}, 38 tangled.Pipeline_Workflow{}, 39 tangled.PublicKey{}, 40 tangled.Repo{}, 41 tangled.RepoArtifact{}, 42 + tangled.RepoCollaborator{}, 43 tangled.RepoIssue{}, 44 tangled.RepoIssueComment{}, 45 tangled.RepoIssueState{}, ··· 47 tangled.RepoPullComment{}, 48 tangled.RepoPull_Source{}, 49 tangled.RepoPullStatus{}, 50 + tangled.RepoPull_Target{}, 51 tangled.Spindle{}, 52 tangled.SpindleMember{}, 53 + tangled.String{}, 54 ); err != nil { 55 panic(err) 56 }
+4
cmd/genjwks/main.go
··· 30 panic(err) 31 } 32 33 b, err := json.Marshal(key) 34 if err != nil { 35 panic(err)
··· 30 panic(err) 31 } 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 37 b, err := json.Marshal(key) 38 if err != nil { 39 panic(err)
+1 -1
cmd/punchcardPopulate/main.go
··· 11 ) 12 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 15 if err != nil { 16 log.Fatal("Failed to open database:", err) 17 }
··· 11 ) 12 13 func main() { 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 if err != nil { 16 log.Fatal("Failed to open database:", err) 17 }
+44
contrib/Tiltfile
···
··· 1 + common_env = { 2 + "TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""), 3 + "TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""), 4 + "TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"), 5 + "TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"), 6 + } 7 + 8 + nix_globs = ["nix/**", "flake.nix", "flake.lock"] 9 + 10 + local_resource( 11 + name="appview", 12 + serve_cmd="nix run .#watch-appview", 13 + serve_dir="..", 14 + deps=nix_globs, 15 + env=common_env, 16 + allow_parallel=True, 17 + ) 18 + 19 + local_resource( 20 + name="tailwind", 21 + serve_cmd="nix run .#watch-tailwind", 22 + serve_dir="..", 23 + deps=nix_globs, 24 + env=common_env, 25 + allow_parallel=True, 26 + ) 27 + 28 + local_resource( 29 + name="redis", 30 + serve_cmd="redis-server", 31 + serve_dir="..", 32 + deps=nix_globs, 33 + env=common_env, 34 + allow_parallel=True, 35 + ) 36 + 37 + local_resource( 38 + name="vm", 39 + serve_cmd="nix run --impure .#vm", 40 + serve_dir="..", 41 + deps=nix_globs, 42 + env=common_env, 43 + allow_parallel=True, 44 + )
+16
default.nix
···
··· 1 + # Default setup from https://git.lix.systems/lix-project/flake-compat 2 + let 3 + lockFile = builtins.fromJSON (builtins.readFile ./flake.lock); 4 + flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat}; 5 + flake-compat = builtins.fetchTarball { 6 + inherit (flake-compat-node.locked) url; 7 + sha256 = flake-compat-node.locked.narHash; 8 + }; 9 + 10 + flake = ( 11 + import flake-compat { 12 + src = ./.; 13 + } 14 + ); 15 + in 16 + flake.defaultNix
+17 -18
docs/contributing.md
··· 11 ### message format 12 13 ``` 14 - <service/top-level directory>: <affected package/directory>: <short summary of change> 15 16 17 Optional longer description can go here, if necessary. Explain what the ··· 23 Here are some examples: 24 25 ``` 26 - appview: state: fix token expiry check in middleware 27 28 The previous check did not account for clock drift, leading to premature 29 token invalidation. 30 ``` 31 32 ``` 33 - knotserver: git/service: improve error checking in upload-pack 34 ``` 35 36 ··· 54 - Don't include unrelated changes in the same commit. 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 before submitting if necessary. 57 58 ## proposals for bigger changes 59 ··· 115 If you're submitting a PR with multiple commits, make sure each one is 116 signed. 117 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 120 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 - ), 133 ``` 134 135 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 137 for more information.
··· 11 ### message format 12 13 ``` 14 + <service/top-level directory>/<affected package/directory>: <short summary of change> 15 16 17 Optional longer description can go here, if necessary. Explain what the ··· 23 Here are some examples: 24 25 ``` 26 + appview/state: fix token expiry check in middleware 27 28 The previous check did not account for clock drift, leading to premature 29 token invalidation. 30 ``` 31 32 ``` 33 + knotserver/git/service: improve error checking in upload-pack 34 ``` 35 36 ··· 54 - Don't include unrelated changes in the same commit. 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 before submitting if necessary. 57 + 58 + ## code formatting 59 + 60 + We use a variety of tools to format our code, and multiplex them with 61 + [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 64 ## proposals for bigger changes 65 ··· 121 If you're submitting a PR with multiple commits, make sure each one is 122 signed. 123 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: 126 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)" 132 ``` 133 134 Refer to the [jj 135 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 for more information.
+66 -20
docs/hacking.md
··· 48 redis-server 49 ``` 50 51 - ## running a knot 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 - To begin, head to `http://localhost:3000/knots` in the browser 59 - and generate a knot secret. Replace the existing secret in 60 - `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 - secret. 62 63 - You can now start a lightweight NixOS VM using 64 - `nixos-shell` like so: 65 66 ```bash 67 - nix run .#vm 68 - # or nixos-shell --flake .#vm 69 70 - # hit Ctrl-a + c + q to exit the VM 71 ``` 72 73 This starts a knot on port 6000, a spindle on port 6555 74 - with `ssh` exposed on port 2222. You can push repositories 75 - to this VM with this ssh config block on your main machine: 76 77 ```bash 78 Host nixos-shell ··· 89 git push local-dev main 90 ``` 91 92 - ## running a spindle 93 94 - Be sure to change the `owner` field for the spindle in 95 - `nix/vm.nix` to your own DID. The above VM should already 96 - be running a spindle on `localhost:6555`. You can head to 97 - the spindle dashboard on `http://localhost:3000/spindles`, 98 - and register a spindle with hostname `localhost:6555`. It 99 - should instantly be verified. You can then configure each 100 - repository to use this spindle and run CI jobs. 101 102 Of interest when debugging spindles: 103 ··· 114 # litecli has a nicer REPL interface: 115 litecli /var/lib/spindle/spindle.db 116 ```
··· 48 redis-server 49 ``` 50 51 + ## running knots and spindles 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 60 + 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 90 + 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 96 + 97 + </details> 98 + 99 + To begin, grab your DID from http://localhost:3000/settings. 100 + Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 103 104 ```bash 105 + nix run --impure .#vm 106 107 + # type `poweroff` at the shell to exit the VM 108 ``` 109 110 This starts a knot on port 6000, a spindle on port 6555 111 + with `ssh` exposed on port 2222. 112 + 113 + Once the services are running, head to 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 117 + 118 + You can push repositories to this VM with this ssh config 119 + block on your main machine: 120 121 ```bash 122 Host nixos-shell ··· 133 git push local-dev main 134 ``` 135 136 + ### running a spindle 137 138 + The above VM should already be running a spindle on 139 + `localhost:6555`. Head to http://localhost:3000/spindles and 140 + hit verify. You can then configure each repository to use 141 + this spindle and run CI jobs. 142 143 Of interest when debugging spindles: 144 ··· 155 # litecli has a nicer REPL interface: 156 litecli /var/lib/spindle/spindle.db 157 ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
+27 -7
docs/knot-hosting.md
··· 2 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 3. A valid SSL certificate for your domain. 8 ··· 59 EOF 60 ``` 61 62 Next, create the `git` user. We'll use the `git` user's home directory 63 to store repositories: 64 ··· 67 ``` 68 69 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. 72 73 ``` 74 KNOT_REPO_SCAN_PATH=/home/git 75 KNOT_SERVER_HOSTNAME=knot.example.com 76 APPVIEW_ENDPOINT=https://tangled.sh 77 - KNOT_SERVER_SECRET=secret 78 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 79 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 80 ``` ··· 89 systemctl start knotserver 90 ``` 91 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 93 knot. Here's an example configuration for Nginx: 94 95 ``` ··· 122 Remember to use Let's Encrypt or similar to procure a certificate for your 123 knot domain. 124 125 - You should now have a running knot server! You can finalize your registration by hitting the 126 - `initialize` button on the [/knots](/knots) page. 127 128 ### custom paths 129 ··· 191 ``` 192 193 Make sure to restart your SSH server!
··· 2 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 3. A valid SSL certificate for your domain. 8 ··· 59 EOF 60 ``` 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 68 Next, create the `git` user. We'll use the `git` user's home directory 69 to store repositories: 70 ··· 73 ``` 74 75 Create `/home/git/.knot.env` with the following, updating the values as 76 + necessary. The `KNOT_SERVER_OWNER` should be set to your 77 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 78 79 ``` 80 KNOT_REPO_SCAN_PATH=/home/git 81 KNOT_SERVER_HOSTNAME=knot.example.com 82 APPVIEW_ENDPOINT=https://tangled.sh 83 + KNOT_SERVER_OWNER=did:plc:foobar 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 86 ``` ··· 95 systemctl start knotserver 96 ``` 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 ``` ··· 128 Remember to use Let's Encrypt or similar to procure a certificate for your 129 knot domain. 130 131 + You should now have a running knot server! You can finalize 132 + your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.sh/knots) page. This simply creates 134 + a record on your PDS to announce the existence of the knot. 135 136 ### custom paths 137 ··· 199 ``` 200 201 Make sure to restart your SSH server! 202 + 203 + #### MOTD (message of the day) 204 + 205 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 206 + `/home/git/motd` file: 207 + 208 + ``` 209 + printf "Hi from this knot!\n" > /home/git/motd 210 + ``` 211 + 212 + Note that you should add a newline at the end if setting a non-empty message 213 + since the knot won't do this for you.
+60
docs/migrations.md
···
··· 1 + # Migrations 2 + 3 + This document is laid out in reverse-chronological order. 4 + Newer migration guides are listed first, and older guides 5 + are further down the page. 6 + 7 + ## Upgrading from v1.8.x 8 + 9 + After v1.8.2, the HTTP API for knot and spindles have been 10 + deprecated and replaced with XRPC. Repositories on outdated 11 + knots will not be viewable from the appview. Upgrading is 12 + straightforward however. 13 + 14 + For knots: 15 + 16 + - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.sh/knots) and 18 + hit the "retry" button to verify your knot 19 + 20 + For spindles: 21 + 22 + - Upgrade to latest tag (v1.9.0 or above) 23 + - Head to the [spindle 24 + dashboard](https://tangled.sh/spindles) and hit the 25 + "retry" button to verify your spindle 26 + 27 + ## Upgrading from v1.7.x 28 + 29 + After v1.7.0, knot secrets have been deprecated. You no 30 + longer need a secret from the appview to run a knot. All 31 + authorized commands to knots are managed via [Inter-Service 32 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 + Knots will be read-only until upgraded. 34 + 35 + Upgrading is quite easy, in essence: 36 + 37 + - `KNOT_SERVER_SECRET` is no more, you can remove this 38 + environment variable entirely 39 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 + your DID. You can find your DID in the 41 + [settings](https://tangled.sh/settings) page. 42 + - Restart your knot once you have replaced the environment 43 + variable 44 + - Head to the [knot dashboard](https://tangled.sh/knots) and 45 + hit the "retry" button to verify your knot. This simply 46 + writes a `sh.tangled.knot` record to your PDS. 47 + 48 + If you use the nix module, simply bump the flake to the 49 + latest revision, and change your config block like so: 50 + 51 + ```diff 52 + services.tangled-knot = { 53 + enable = true; 54 + server = { 55 + - secretFile = /path/to/secret; 56 + + owner = "did:plc:foo"; 57 + }; 58 + }; 59 + ``` 60 +
+193 -38
docs/spindle/openbao.md
··· 1 # spindle secrets with openbao 2 3 This document covers setting up Spindle to use OpenBao for secrets 4 - management instead of the default SQLite backend. 5 6 ## installation 7 8 Install OpenBao from nixpkgs: 9 10 ```bash 11 - nix-env -iA nixpkgs.openbao 12 ``` 13 14 - ## local development setup 15 16 Start OpenBao in dev mode: 17 18 ```bash 19 - bao server -dev 20 ``` 21 22 - This starts OpenBao on `http://localhost:8200` with a root token. Save 23 - the root token from the output -- you'll need it. 24 25 Set up environment for bao CLI: 26 27 ```bash 28 export BAO_ADDR=http://localhost:8200 29 - export BAO_TOKEN=hvs.your-root-token-here 30 ``` 31 32 Create the spindle KV mount: 33 34 ```bash 35 bao secrets enable -path=spindle -version=2 kv 36 ``` 37 38 - Set up AppRole authentication: 39 40 Create a policy file `spindle-policy.hcl`: 41 42 ```hcl 43 path "spindle/data/*" { 44 - capabilities = ["create", "read", "update", "delete", "list"] 45 } 46 47 path "spindle/metadata/*" { 48 - capabilities = ["list", "read", "delete"] 49 } 50 51 - path "spindle/*" { 52 capabilities = ["list"] 53 } 54 ``` 55 56 Apply the policy and create an AppRole: ··· 61 bao write auth/approle/role/spindle \ 62 token_policies="spindle-policy" \ 63 token_ttl=1h \ 64 - token_max_ttl=4h 65 ``` 66 67 Get the credentials: 68 69 ```bash 70 - bao read auth/approle/role/spindle/role-id 71 - bao write -f auth/approle/role/spindle/secret-id 72 ``` 73 74 - Configure Spindle: 75 76 Set these environment variables for Spindle: 77 78 ```bash 79 export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 80 - export SPINDLE_SERVER_SECRETS_OPENBAO_ADDR=http://localhost:8200 81 - export SPINDLE_SERVER_SECRETS_OPENBAO_ROLE_ID=your-role-id-from-above 82 - export SPINDLE_SERVER_SECRETS_OPENBAO_SECRET_ID=your-secret-id-from-above 83 export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 84 ``` 85 86 Start Spindle: 87 88 - Spindle will now use OpenBao for secrets storage with automatic token 89 - renewal. 90 91 ## verifying setup 92 93 - List all secrets: 94 95 ```bash 96 - bao kv list spindle/ 97 ``` 98 99 - Add a test secret via Spindle API, then check it exists: 100 101 ```bash 102 bao kv list spindle/repos/ 103 - ``` 104 105 - Get a specific secret: 106 - 107 - ```bash 108 bao kv get spindle/repos/your_repo_path/SECRET_NAME 109 ``` 110 111 ## how it works 112 113 - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 114 - - Each repository gets its own namespace 115 - - Repository paths like `at://did:plc:alice/myrepo` become 116 - `at_did_plc_alice_myrepo` 117 - - The system automatically handles token renewal using AppRole 118 - authentication 119 - - On shutdown, Spindle cleanly stops the token renewal process 120 121 ## troubleshooting 122 123 - **403 errors**: Check that your BAO_TOKEN is set and the spindle mount 124 - exists 125 126 **404 route errors**: The spindle KV mount probably doesn't exist - run 127 - the mount creation step again 128 129 - **Token expired**: The AppRole system should handle this automatically, 130 - but you can check token status with `bao token lookup`
··· 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: ··· 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 + ```
+140 -41
docs/spindle/pipeline.md
··· 1 - # spindle pipeline manifest 2 3 - Spindle pipelines are defined under the `.tangled/workflows` directory in a 4 - repo. Generally: 5 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. 10 11 - Here's an example that uses all fields: 12 13 ```yaml 14 - # build_and_test.yaml 15 when: 16 - - event: ["push", "pull_request"] 17 branch: ["main", "develop"] 18 - - event: ["manual"] 19 20 dependencies: 21 - ## from nixpkgs 22 nixpkgs: 23 - nodejs 24 - ## custom registry 25 - git+https://tangled.sh/@oppi.li/statix: 26 - - statix 27 28 - steps: 29 - - name: "Install dependencies" 30 - command: "npm install" 31 - environment: 32 - NODE_ENV: "development" 33 - CI: "true" 34 35 - - name: "Run linter" 36 - command: "npm run lint" 37 38 - - name: "Run tests" 39 - command: "npm test" 40 environment: 41 - NODE_ENV: "test" 42 - JEST_WORKERS: "2" 43 - 44 - - name: "Build application" 45 command: "npm run build" 46 environment: 47 NODE_ENV: "production" 48 49 - environment: 50 - BUILD_NUMBER: "123" 51 - GIT_BRANCH: "main" 52 53 - ## current repository is cloned and checked out at the target ref 54 - ## by default. 55 clone: 56 skip: false 57 - depth: 50 58 - submodules: true 59 - ``` 60 61 - ## git push options 62 63 - These are push options that can be used with the `--push-option (-o)` flag of git push: 64 65 - - `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push. 66 - - `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
··· 1 + # spindle pipelines 2 + 3 + Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 + 5 + The fields are: 6 7 + - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 + - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 + - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 + - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 + - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 + - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 14 + ## Trigger 15 16 + The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 + 18 + - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 + - `push`: The workflow should run every time a commit is pushed to the repository. 20 + - `pull_request`: The workflow should run every time a pull request is made or updated. 21 + - `manual`: The workflow can be triggered manually. 22 + - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 + 24 + For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when: 28 + - event: ["push", "manual"] 29 branch: ["main", "develop"] 30 + - event: ["pull_request"] 31 + branch: ["main"] 32 + ``` 33 + 34 + ## Engine 35 + 36 + Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 37 + 38 + - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 39 + 40 + Example: 41 + 42 + ```yaml 43 + engine: "nixery" 44 + ``` 45 + 46 + ## Clone options 47 + 48 + When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 49 + 50 + - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 51 + - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 52 + - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 53 + 54 + The default settings are: 55 + 56 + ```yaml 57 + clone: 58 + skip: false 59 + depth: 1 60 + submodules: false 61 + ``` 62 + 63 + ## Dependencies 64 + 65 + Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 66 + 67 + Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 68 69 + ```yaml 70 dependencies: 71 + # nixpkgs 72 nixpkgs: 73 - nodejs 74 + - go 75 + # custom registry 76 + git+https://tangled.sh/@example.com/my_pkg: 77 + - my_pkg 78 + ``` 79 + 80 + Now these dependencies are available to use in your workflow! 81 + 82 + ## Environment 83 + 84 + The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 85 + 86 + Example: 87 + 88 + ```yaml 89 + environment: 90 + GOOS: "linux" 91 + GOARCH: "arm64" 92 + NODE_ENV: "production" 93 + MY_ENV_VAR: "MY_ENV_VALUE" 94 + ``` 95 96 + ## Steps 97 + 98 + The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 99 + 100 + - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 101 + - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 102 + - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 104 + Example: 105 106 + ```yaml 107 + steps: 108 + - name: "Build backend" 109 + command: "go build" 110 environment: 111 + GOOS: "darwin" 112 + GOARCH: "arm64" 113 + - name: "Build frontend" 114 command: "npm run build" 115 environment: 116 NODE_ENV: "production" 117 + ``` 118 119 + ## Complete workflow 120 121 + ```yaml 122 + # .tangled/workflows/build.yml 123 + 124 + when: 125 + - event: ["push", "manual"] 126 + branch: ["main", "develop"] 127 + - event: ["pull_request"] 128 + branch: ["main"] 129 + 130 + engine: "nixery" 131 + 132 + # using the default values 133 clone: 134 skip: false 135 + depth: 1 136 + submodules: false 137 + 138 + dependencies: 139 + # nixpkgs 140 + nixpkgs: 141 + - nodejs 142 + - go 143 + # custom registry 144 + git+https://tangled.sh/@example.com/my_pkg: 145 + - my_pkg 146 147 + environment: 148 + GOOS: "linux" 149 + GOARCH: "arm64" 150 + NODE_ENV: "production" 151 + MY_ENV_VAR: "MY_ENV_VALUE" 152 153 + steps: 154 + - name: "Build backend" 155 + command: "go build" 156 + environment: 157 + GOOS: "darwin" 158 + GOARCH: "arm64" 159 + - name: "Build frontend" 160 + command: "npm run build" 161 + environment: 162 + NODE_ENV: "production" 163 + ``` 164 165 + If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 } 22 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 25 if err != nil { 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 }
··· 21 } 22 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 if err != nil { 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 }
+20 -26
flake.lock
··· 1 { 2 "nodes": { 3 - "gitignore": { 4 - "inputs": { 5 - "nixpkgs": [ 6 - "nixpkgs" 7 - ] 8 - }, 9 "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 15 - "type": "github" 16 }, 17 "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 20 - "type": "github" 21 } 22 }, 23 "flake-utils": { ··· 46 ] 47 }, 48 "locked": { 49 - "lastModified": 1751702058, 50 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 51 "owner": "nix-community", 52 "repo": "gomod2nix", 53 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 54 "type": "github" 55 }, 56 "original": { ··· 99 "indigo": { 100 "flake": false, 101 "locked": { 102 - "lastModified": 1745333930, 103 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 104 "owner": "oppiliappan", 105 "repo": "indigo", 106 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 107 "type": "github" 108 }, 109 "original": { ··· 128 "lucide-src": { 129 "flake": false, 130 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 133 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 135 }, 136 "original": { 137 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 139 } 140 }, 141 "nixpkgs": { ··· 156 }, 157 "root": { 158 "inputs": { 159 - "gitignore": "gitignore", 160 "gomod2nix": "gomod2nix", 161 "htmx-src": "htmx-src", 162 "htmx-ws-src": "htmx-ws-src",
··· 1 { 2 "nodes": { 3 + "flake-compat": { 4 + "flake": false, 5 "locked": { 6 + "lastModified": 1751685974, 7 + "narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=", 8 + "rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1", 9 + "type": "tarball", 10 + "url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1" 11 }, 12 "original": { 13 + "type": "tarball", 14 + "url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz" 15 } 16 }, 17 "flake-utils": { ··· 40 ] 41 }, 42 "locked": { 43 + "lastModified": 1754078208, 44 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 45 "owner": "nix-community", 46 "repo": "gomod2nix", 47 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 48 "type": "github" 49 }, 50 "original": { ··· 93 "indigo": { 94 "flake": false, 95 "locked": { 96 + "lastModified": 1753693716, 97 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 98 "owner": "oppiliappan", 99 "repo": "indigo", 100 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 101 "type": "github" 102 }, 103 "original": { ··· 122 "lucide-src": { 123 "flake": false, 124 "locked": { 125 + "lastModified": 1754044466, 126 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 127 "type": "tarball", 128 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 129 }, 130 "original": { 131 "type": "tarball", 132 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 133 } 134 }, 135 "nixpkgs": { ··· 150 }, 151 "root": { 152 "inputs": { 153 + "flake-compat": "flake-compat", 154 "gomod2nix": "gomod2nix", 155 "htmx-src": "htmx-src", 156 "htmx-ws-src": "htmx-ws-src",
+110 -30
flake.nix
··· 7 url = "github:nix-community/gomod2nix"; 8 inputs.nixpkgs.follows = "nixpkgs"; 9 }; 10 indigo = { 11 url = "github:oppiliappan/indigo"; 12 flake = false; ··· 22 flake = false; 23 }; 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 26 flake = false; 27 }; 28 inter-fonts-src = { ··· 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 38 flake = false; 39 }; 40 - gitignore = { 41 - url = "github:hercules-ci/gitignore.nix"; 42 - inputs.nixpkgs.follows = "nixpkgs"; 43 - }; 44 }; 45 46 outputs = { ··· 51 htmx-src, 52 htmx-ws-src, 53 lucide-src, 54 - gitignore, 55 inter-fonts-src, 56 sqlite-lib-src, 57 ibm-plex-mono-src, 58 }: let 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 62 63 mkPackageSet = pkgs: 64 pkgs.lib.makeScope pkgs.newScope (self: { 65 - inherit (gitignore.lib) gitignoreSource; 66 buildGoApplication = 67 (self.callPackage "${gomod2nix}/builder" { 68 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 74 }; 75 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 }; 80 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 92 staticPackages = mkPackageSet pkgs.pkgsStatic; 93 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 94 in { 95 - appview = packages.appview; 96 - lexgen = packages.lexgen; 97 - knot = packages.knot; 98 - knot-unwrapped = packages.knot-unwrapped; 99 - spindle = packages.spindle; 100 - genjwks = packages.genjwks; 101 - sqlite-lib = packages.sqlite-lib; 102 103 pkgsStatic-appview = staticPackages.appview; 104 pkgsStatic-knot = staticPackages.knot; ··· 110 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 111 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 112 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 113 }); 114 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 115 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 116 devShells = forAllSystems (system: let 117 pkgs = nixpkgsFor.${system}; 118 packages' = self.packages.${system}; ··· 124 nativeBuildInputs = [ 125 pkgs.go 126 pkgs.air 127 pkgs.gopls 128 pkgs.httpie 129 pkgs.litecli ··· 131 pkgs.tailwindcss 132 pkgs.nixos-shell 133 pkgs.redis 134 packages'.lexgen 135 ]; 136 shellHook = '' 137 - mkdir -p appview/pages/static/{fonts,icons} 138 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 139 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 140 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 141 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 142 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 143 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 144 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 145 ''; 146 env.CGO_ENABLED = 1; ··· 148 }); 149 apps = forAllSystems (system: let 150 pkgs = nixpkgsFor."${system}"; 151 air-watcher = name: arg: 152 pkgs.writeShellScriptBin "run" 153 '' ··· 161 tailwind-watcher = 162 pkgs.writeShellScriptBin "run" 163 '' 164 - ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 165 ''; 166 in { 167 watch-appview = { 168 type = "app"; 169 - program = ''${air-watcher "appview" ""}/bin/run''; 170 }; 171 watch-knot = { 172 type = "app"; ··· 176 type = "app"; 177 program = ''${tailwind-watcher}/bin/run''; 178 }; 179 - vm = { 180 type = "app"; 181 - program = toString (pkgs.writeShellScript "vm" '' 182 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 183 - ''); 184 }; 185 gomod2nix = { 186 type = "app"; ··· 188 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 ''); 190 }; 191 }); 192 193 nixosModules.appview = { ··· 217 218 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 }; 220 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 221 }; 222 }
··· 7 url = "github:nix-community/gomod2nix"; 8 inputs.nixpkgs.follows = "nixpkgs"; 9 }; 10 + flake-compat = { 11 + url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"; 12 + flake = false; 13 + }; 14 indigo = { 15 url = "github:oppiliappan/indigo"; 16 flake = false; ··· 26 flake = false; 27 }; 28 lucide-src = { 29 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 30 flake = false; 31 }; 32 inter-fonts-src = { ··· 41 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 42 flake = false; 43 }; 44 }; 45 46 outputs = { ··· 51 htmx-src, 52 htmx-ws-src, 53 lucide-src, 54 inter-fonts-src, 55 sqlite-lib-src, 56 ibm-plex-mono-src, 57 + ... 58 }: let 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 62 63 mkPackageSet = pkgs: 64 pkgs.lib.makeScope pkgs.newScope (self: { 65 + src = let 66 + fs = pkgs.lib.fileset; 67 + in 68 + fs.toSource { 69 + root = ./.; 70 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 71 + }; 72 buildGoApplication = 73 (self.callPackage "${gomod2nix}/builder" { 74 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 80 }; 81 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 83 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 85 }; 86 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 88 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 89 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 99 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 in { 102 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 103 104 pkgsStatic-appview = staticPackages.appview; 105 pkgsStatic-knot = staticPackages.knot; ··· 111 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 112 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 113 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 114 + 115 + treefmt-wrapper = pkgs.treefmt.withConfig { 116 + settings.formatter = { 117 + alejandra = { 118 + command = pkgs.lib.getExe pkgs.alejandra; 119 + includes = ["*.nix"]; 120 + }; 121 + 122 + gofmt = { 123 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 124 + options = ["-w"]; 125 + includes = ["*.go"]; 126 + }; 127 + 128 + # prettier = let 129 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 130 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 131 + # ''; 132 + # in { 133 + # command = wrapper; 134 + # options = ["-w"]; 135 + # includes = ["*.html"]; 136 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 137 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 138 + # }; 139 + }; 140 + }; 141 }); 142 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 143 devShells = forAllSystems (system: let 144 pkgs = nixpkgsFor.${system}; 145 packages' = self.packages.${system}; ··· 151 nativeBuildInputs = [ 152 pkgs.go 153 pkgs.air 154 + pkgs.tilt 155 pkgs.gopls 156 pkgs.httpie 157 pkgs.litecli ··· 159 pkgs.tailwindcss 160 pkgs.nixos-shell 161 pkgs.redis 162 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 163 packages'.lexgen 164 + packages'.treefmt-wrapper 165 ]; 166 shellHook = '' 167 + mkdir -p appview/pages/static 168 + # no preserve is needed because watch-tailwind will want to be able to overwrite 169 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 171 ''; 172 env.CGO_ENABLED = 1; ··· 174 }); 175 apps = forAllSystems (system: let 176 pkgs = nixpkgsFor."${system}"; 177 + packages' = self.packages.${system}; 178 air-watcher = name: arg: 179 pkgs.writeShellScriptBin "run" 180 '' ··· 188 tailwind-watcher = 189 pkgs.writeShellScriptBin "run" 190 '' 191 + ${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css 192 ''; 193 in { 194 + fmt = { 195 + type = "app"; 196 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 197 + }; 198 watch-appview = { 199 type = "app"; 200 + program = toString (pkgs.writeShellScript "watch-appview" '' 201 + echo "copying static files to appview/pages/static..." 202 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 203 + ${air-watcher "appview" ""}/bin/run 204 + ''); 205 }; 206 watch-knot = { 207 type = "app"; ··· 211 type = "app"; 212 program = ''${tailwind-watcher}/bin/run''; 213 }; 214 + vm = let 215 + guestSystem = 216 + if pkgs.stdenv.hostPlatform.isAarch64 217 + then "aarch64-linux" 218 + else "x86_64-linux"; 219 + in { 220 type = "app"; 221 + program = 222 + (pkgs.writeShellApplication { 223 + name = "launch-vm"; 224 + text = '' 225 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 226 + cd "$rootDir" 227 + 228 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 229 + 230 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 231 + exec ${pkgs.lib.getExe 232 + (import ./nix/vm.nix { 233 + inherit nixpkgs self; 234 + system = guestSystem; 235 + hostSystem = system; 236 + }).config.system.build.vm} 237 + ''; 238 + }) 239 + + /bin/launch-vm; 240 }; 241 gomod2nix = { 242 type = "app"; ··· 244 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 245 ''); 246 }; 247 + lexgen = { 248 + type = "app"; 249 + program = 250 + (pkgs.writeShellApplication { 251 + name = "lexgen"; 252 + text = '' 253 + if ! command -v lexgen > /dev/null; then 254 + echo "error: must be executed from devshell" 255 + exit 1 256 + fi 257 + 258 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 259 + cd "$rootDir" 260 + 261 + rm -f api/tangled/* 262 + lexgen --build-file lexicon-build-config.json lexicons 263 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 + go run cmd/gen.go 266 + lexgen --build-file lexicon-build-config.json lexicons 267 + rm api/tangled/*.bak 268 + ''; 269 + }) 270 + + /bin/lexgen; 271 + }; 272 }); 273 274 nixosModules.appview = { ··· 298 299 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 300 }; 301 }; 302 }
+7 -1
go.mod
··· 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 github.com/cyphar/filepath-securejoin v0.4.1 16 github.com/dgraph-io/ristretto v0.2.0 17 github.com/docker/docker v28.2.2+incompatible ··· 21 github.com/go-enry/go-enry/v2 v2.9.2 22 github.com/go-git/go-git/v5 v5.14.0 23 github.com/google/uuid v1.6.0 24 github.com/gorilla/sessions v1.4.0 25 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 github.com/hiddeco/sshsig v0.2.0 ··· 37 github.com/stretchr/testify v1.10.0 38 github.com/urfave/cli/v3 v3.3.3 39 github.com/whyrusleeping/cbor-gen v0.3.1 40 - github.com/yuin/goldmark v1.4.13 41 golang.org/x/crypto v0.40.0 42 golang.org/x/net v0.42.0 43 golang.org/x/sync v0.16.0 ··· 85 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 86 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 87 github.com/golang/mock v1.6.0 // indirect 88 github.com/gorilla/css v1.0.1 // indirect 89 github.com/gorilla/securecookie v1.1.2 // indirect 90 github.com/hashicorp/errwrap v1.1.0 // indirect ··· 150 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 151 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 152 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 153 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 154 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 155 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
··· 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 16 github.com/cyphar/filepath-securejoin v0.4.1 17 github.com/dgraph-io/ristretto v0.2.0 18 github.com/docker/docker v28.2.2+incompatible ··· 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 + github.com/gorilla/feeds v1.2.0 26 github.com/gorilla/sessions v1.4.0 27 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 28 github.com/hiddeco/sshsig v0.2.0 ··· 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.12 44 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 47 golang.org/x/sync v0.16.0 ··· 89 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 90 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 91 github.com/golang/mock v1.6.0 // indirect 92 + github.com/google/go-querystring v1.1.0 // indirect 93 github.com/gorilla/css v1.0.1 // indirect 94 github.com/gorilla/securecookie v1.1.2 // indirect 95 github.com/hashicorp/errwrap v1.1.0 // indirect ··· 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+17 -1
go.sum
··· 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 77 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 78 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 79 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 80 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 81 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 82 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 152 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 153 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 154 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 155 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 156 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 157 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 158 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 159 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 160 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 161 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 162 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 168 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 169 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 170 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 171 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 172 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 173 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= ··· 418 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 419 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 420 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 421 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 422 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 423 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 424 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 425 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 426 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 427 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 428 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 429 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
··· 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 79 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 80 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 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= 83 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 84 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 85 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 155 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 156 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 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= 159 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 160 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 161 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 162 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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= 166 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 167 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 168 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 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= 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= ··· 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 429 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 430 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 + github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 + github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 + github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 + github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+19 -3
guard/guard.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "net/url" ··· 43 Usage: "internal API endpoint", 44 Value: "http://localhost:5444", 45 }, 46 }, 47 } 48 } ··· 54 gitDir := cmd.String("git-dir") 55 logPath := cmd.String("log-path") 56 endpoint := cmd.String("internal-api") 57 58 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 if err != nil { ··· 149 "fullPath", fullPath, 150 "client", clientIP) 151 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 154 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 156 } 157 158 gitCmd := exec.Command(gitCommand, fullPath) 159 gitCmd.Stdout = os.Stdout
··· 2 3 import ( 4 "context" 5 + "errors" 6 "fmt" 7 + "io" 8 "log/slog" 9 "net/http" 10 "net/url" ··· 45 Usage: "internal API endpoint", 46 Value: "http://localhost:5444", 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 53 }, 54 } 55 } ··· 61 gitDir := cmd.String("git-dir") 62 logPath := cmd.String("log-path") 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 65 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 67 if err != nil { ··· 157 "fullPath", fullPath, 158 "client", clientIP) 159 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 166 } else { 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 171 } 172 + io.Copy(os.Stderr, motdReader) 173 174 gitCmd := exec.Command(gitCommand, fullPath) 175 gitCmd.Stdout = os.Stdout
+86 -12
input.css
··· 13 @font-face { 14 font-family: "InterVariable"; 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 17 font-style: italic; 18 font-display: swap; 19 } 20 21 @font-face { 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 25 font-style: normal; 26 font-display: swap; 27 } 28 29 @font-face { 30 font-family: "IBMPlexMono"; 31 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 32 font-weight: normal; 33 font-style: italic; 34 font-display: swap; 35 } ··· 46 @supports (font-variation-settings: normal) { 47 html { 48 font-feature-settings: 49 - "ss01" 1, 50 "kern" 1, 51 "liga" 1, 52 "cv05" 1, ··· 59 } 60 61 label { 62 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 63 } 64 input { 65 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; ··· 70 details summary::-webkit-details-marker { 71 display: none; 72 } 73 } 74 75 @layer components { ··· 98 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 99 } 100 101 .prose img { 102 display: inline; 103 margin: 0; 104 vertical-align: middle; 105 } 106 } 107 @layer utilities { 108 .error { ··· 122 /* PreWrapper */ 123 .chroma { 124 color: #4c4f69; 125 - background-color: #eff1f5; 126 } 127 /* Error */ 128 .chroma .err { ··· 150 } 151 /* LineHighlight */ 152 .chroma .hl { 153 - background-color: #bcc0cc; 154 } 155 /* LineNumbersTable */ 156 .chroma .lnt { 157 white-space: pre; ··· 459 /* PreWrapper */ 460 .chroma { 461 color: #cad3f5; 462 - background-color: #24273a; 463 } 464 /* Error */ 465 .chroma .err { ··· 787 text-decoration: underline; 788 } 789 } 790 - 791 - .chroma .line:has(.ln:target) { 792 - @apply bg-amber-400/30 dark:bg-amber-500/20; 793 - }
··· 13 @font-face { 14 font-family: "InterVariable"; 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 + font-weight: normal; 17 font-style: italic; 18 font-display: swap; 19 } 20 21 @font-face { 22 font-family: "InterVariable"; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 font-style: normal; 26 font-display: swap; 27 } 28 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 { 38 font-family: "IBMPlexMono"; 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 40 font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 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; 65 font-style: italic; 66 font-display: swap; 67 } ··· 78 @supports (font-variation-settings: normal) { 79 html { 80 font-feature-settings: 81 "kern" 1, 82 "liga" 1, 83 "cv05" 1, ··· 90 } 91 92 label { 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; ··· 101 details summary::-webkit-details-marker { 102 display: none; 103 } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + } 108 } 109 110 @layer components { ··· 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 134 } 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 + 172 .prose img { 173 display: inline; 174 margin: 0; 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; 184 + } 185 } 186 @layer utilities { 187 .error { ··· 201 /* PreWrapper */ 202 .chroma { 203 color: #4c4f69; 204 } 205 /* Error */ 206 .chroma .err { ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 + 234 /* LineNumbersTable */ 235 .chroma .lnt { 236 white-space: pre; ··· 538 /* PreWrapper */ 539 .chroma { 540 color: #cad3f5; 541 } 542 /* Error */ 543 .chroma .err { ··· 865 text-decoration: underline; 866 } 867 }
+19 -4
jetstream/jetstream.go
··· 52 j.mu.Unlock() 53 } 54 55 type processor func(context.Context, *models.Event) error 56 57 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 58 - // empty filter => all dids allowed 59 - if len(j.wantedDids) == 0 { 60 - return processFunc 61 - } 62 // since this closure references j.WantedDids; it should auto-update 63 // existing instances of the closure when j.WantedDids is mutated 64 return func(ctx context.Context, evt *models.Event) error { 65 if _, ok := j.wantedDids[evt.Did]; ok { 66 return processFunc(ctx, evt) 67 } else {
··· 52 j.mu.Unlock() 53 } 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 68 type processor func(context.Context, *models.Event) error 69 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 71 // since this closure references j.WantedDids; it should auto-update 72 // existing instances of the closure when j.WantedDids is mutated 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 + 80 if _, ok := j.wantedDids[evt.Did]; ok { 81 return processFunc(ctx, evt) 82 } else {
-336
knotclient/signer.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log" 12 - "net/http" 13 - "net/url" 14 - "time" 15 - 16 - "tangled.sh/tangled.sh/core/types" 17 - ) 18 - 19 - type SignerTransport struct { 20 - Secret string 21 - } 22 - 23 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 - timestamp := time.Now().Format(time.RFC3339) 25 - mac := hmac.New(sha256.New, []byte(s.Secret)) 26 - message := req.Method + req.URL.Path + timestamp 27 - mac.Write([]byte(message)) 28 - signature := hex.EncodeToString(mac.Sum(nil)) 29 - req.Header.Set("X-Signature", signature) 30 - req.Header.Set("X-Timestamp", timestamp) 31 - return http.DefaultTransport.RoundTrip(req) 32 - } 33 - 34 - type SignedClient struct { 35 - Secret string 36 - Url *url.URL 37 - client *http.Client 38 - } 39 - 40 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 - client := &http.Client{ 42 - Timeout: 5 * time.Second, 43 - Transport: SignerTransport{ 44 - Secret: secret, 45 - }, 46 - } 47 - 48 - scheme := "https" 49 - if dev { 50 - scheme = "http" 51 - } 52 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - signedClient := &SignedClient{ 58 - Secret: secret, 59 - client: client, 60 - Url: url, 61 - } 62 - 63 - return signedClient, nil 64 - } 65 - 66 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 - } 69 - 70 - func (s *SignedClient) Init(did string) (*http.Response, error) { 71 - const ( 72 - Method = "POST" 73 - Endpoint = "/init" 74 - ) 75 - 76 - body, _ := json.Marshal(map[string]any{ 77 - "did": did, 78 - }) 79 - 80 - req, err := s.newRequest(Method, Endpoint, body) 81 - if err != nil { 82 - return nil, err 83 - } 84 - 85 - return s.client.Do(req) 86 - } 87 - 88 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 - const ( 90 - Method = "PUT" 91 - Endpoint = "/repo/new" 92 - ) 93 - 94 - body, _ := json.Marshal(map[string]any{ 95 - "did": did, 96 - "name": repoName, 97 - "default_branch": defaultBranch, 98 - }) 99 - 100 - req, err := s.newRequest(Method, Endpoint, body) 101 - if err != nil { 102 - return nil, err 103 - } 104 - 105 - return s.client.Do(req) 106 - } 107 - 108 - func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 - const ( 110 - Method = "GET" 111 - ) 112 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 - 114 - req, err := s.newRequest(Method, endpoint, nil) 115 - if err != nil { 116 - return nil, err 117 - } 118 - 119 - resp, err := s.client.Do(req) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - var result types.RepoLanguageResponse 125 - if resp.StatusCode != http.StatusOK { 126 - log.Println("failed to calculate languages", resp.Status) 127 - return &types.RepoLanguageResponse{}, nil 128 - } 129 - 130 - body, err := io.ReadAll(resp.Body) 131 - if err != nil { 132 - return nil, err 133 - } 134 - 135 - err = json.Unmarshal(body, &result) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return &result, nil 141 - } 142 - 143 - func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 - const ( 145 - Method = "GET" 146 - ) 147 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": ownerDid, 151 - "source": source, 152 - "name": name, 153 - "hiddenref": hiddenRef, 154 - }) 155 - 156 - req, err := s.newRequest(Method, endpoint, body) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - return s.client.Do(req) 162 - } 163 - 164 - func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 - 170 - body, _ := json.Marshal(map[string]any{ 171 - "did": ownerDid, 172 - "source": source, 173 - "name": name, 174 - }) 175 - 176 - req, err := s.newRequest(Method, endpoint, body) 177 - if err != nil { 178 - return nil, err 179 - } 180 - 181 - return s.client.Do(req) 182 - } 183 - 184 - func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 - const ( 186 - Method = "POST" 187 - Endpoint = "/repo/fork" 188 - ) 189 - 190 - body, _ := json.Marshal(map[string]any{ 191 - "did": ownerDid, 192 - "source": source, 193 - "name": name, 194 - }) 195 - 196 - req, err := s.newRequest(Method, Endpoint, body) 197 - if err != nil { 198 - return nil, err 199 - } 200 - 201 - return s.client.Do(req) 202 - } 203 - 204 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 - const ( 206 - Method = "DELETE" 207 - Endpoint = "/repo" 208 - ) 209 - 210 - body, _ := json.Marshal(map[string]any{ 211 - "did": did, 212 - "name": repoName, 213 - }) 214 - 215 - req, err := s.newRequest(Method, Endpoint, body) 216 - if err != nil { 217 - return nil, err 218 - } 219 - 220 - return s.client.Do(req) 221 - } 222 - 223 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 - const ( 225 - Method = "PUT" 226 - Endpoint = "/member/add" 227 - ) 228 - 229 - body, _ := json.Marshal(map[string]any{ 230 - "did": did, 231 - }) 232 - 233 - req, err := s.newRequest(Method, Endpoint, body) 234 - if err != nil { 235 - return nil, err 236 - } 237 - 238 - return s.client.Do(req) 239 - } 240 - 241 - func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 - const ( 243 - Method = "PUT" 244 - ) 245 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 - 247 - body, _ := json.Marshal(map[string]any{ 248 - "branch": branch, 249 - }) 250 - 251 - req, err := s.newRequest(Method, endpoint, body) 252 - if err != nil { 253 - return nil, err 254 - } 255 - 256 - return s.client.Do(req) 257 - } 258 - 259 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 - const ( 261 - Method = "POST" 262 - ) 263 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 - 265 - body, _ := json.Marshal(map[string]any{ 266 - "did": memberDid, 267 - }) 268 - 269 - req, err := s.newRequest(Method, endpoint, body) 270 - if err != nil { 271 - return nil, err 272 - } 273 - 274 - return s.client.Do(req) 275 - } 276 - 277 - func (s *SignedClient) Merge( 278 - patch []byte, 279 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 - ) (*http.Response, error) { 281 - const ( 282 - Method = "POST" 283 - ) 284 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 - 286 - mr := types.MergeRequest{ 287 - Branch: branch, 288 - CommitMessage: commitMessage, 289 - CommitBody: commitBody, 290 - AuthorName: authorName, 291 - AuthorEmail: authorEmail, 292 - Patch: string(patch), 293 - } 294 - 295 - body, _ := json.Marshal(mr) 296 - 297 - req, err := s.newRequest(Method, endpoint, body) 298 - if err != nil { 299 - return nil, err 300 - } 301 - 302 - return s.client.Do(req) 303 - } 304 - 305 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 - const ( 307 - Method = "POST" 308 - ) 309 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 - 311 - body, _ := json.Marshal(map[string]any{ 312 - "patch": string(patch), 313 - "branch": branch, 314 - }) 315 - 316 - req, err := s.newRequest(Method, endpoint, body) 317 - if err != nil { 318 - return nil, err 319 - } 320 - 321 - return s.client.Do(req) 322 - } 323 - 324 - func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 - const ( 326 - Method = "POST" 327 - ) 328 - endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 - 330 - req, err := s.newRequest(Method, endpoint, nil) 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return s.client.Do(req) 336 - }
···
-250
knotclient/unsigned.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "strconv" 12 - "time" 13 - 14 - "tangled.sh/tangled.sh/core/types" 15 - ) 16 - 17 - type UnsignedClient struct { 18 - Url *url.URL 19 - client *http.Client 20 - } 21 - 22 - func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 23 - client := &http.Client{ 24 - Timeout: 5 * time.Second, 25 - } 26 - 27 - scheme := "https" 28 - if dev { 29 - scheme = "http" 30 - } 31 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 32 - if err != nil { 33 - return nil, err 34 - } 35 - 36 - unsignedClient := &UnsignedClient{ 37 - client: client, 38 - Url: url, 39 - } 40 - 41 - return unsignedClient, nil 42 - } 43 - 44 - func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) { 45 - reqUrl := us.Url.JoinPath(endpoint) 46 - 47 - // add query parameters 48 - if query != nil { 49 - reqUrl.RawQuery = query.Encode() 50 - } 51 - 52 - return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body)) 53 - } 54 - 55 - func do[T any](us *UnsignedClient, req *http.Request) (*T, error) { 56 - resp, err := us.client.Do(req) 57 - if err != nil { 58 - return nil, err 59 - } 60 - defer resp.Body.Close() 61 - 62 - body, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - log.Printf("Error reading response body: %v", err) 65 - return nil, err 66 - } 67 - 68 - var result T 69 - err = json.Unmarshal(body, &result) 70 - if err != nil { 71 - log.Printf("Error unmarshalling response body: %v", err) 72 - return nil, err 73 - } 74 - 75 - return &result, nil 76 - } 77 - 78 - func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) { 79 - const ( 80 - Method = "GET" 81 - ) 82 - 83 - endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 84 - if ref == "" { 85 - endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 86 - } 87 - 88 - req, err := us.newRequest(Method, endpoint, nil, nil) 89 - if err != nil { 90 - return nil, err 91 - } 92 - 93 - return do[types.RepoIndexResponse](us, req) 94 - } 95 - 96 - func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) { 97 - const ( 98 - Method = "GET" 99 - ) 100 - 101 - endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref)) 102 - 103 - query := url.Values{} 104 - query.Add("page", strconv.Itoa(page)) 105 - query.Add("per_page", strconv.Itoa(60)) 106 - 107 - req, err := us.newRequest(Method, endpoint, query, nil) 108 - if err != nil { 109 - return nil, err 110 - } 111 - 112 - return do[types.RepoLogResponse](us, req) 113 - } 114 - 115 - func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) { 116 - const ( 117 - Method = "GET" 118 - ) 119 - 120 - endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 121 - 122 - req, err := us.newRequest(Method, endpoint, nil, nil) 123 - if err != nil { 124 - return nil, err 125 - } 126 - 127 - return do[types.RepoBranchesResponse](us, req) 128 - } 129 - 130 - func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 131 - const ( 132 - Method = "GET" 133 - ) 134 - 135 - endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName) 136 - 137 - req, err := us.newRequest(Method, endpoint, nil, nil) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - return do[types.RepoTagsResponse](us, req) 143 - } 144 - 145 - func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) { 146 - const ( 147 - Method = "GET" 148 - ) 149 - 150 - endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch)) 151 - 152 - req, err := us.newRequest(Method, endpoint, nil, nil) 153 - if err != nil { 154 - return nil, err 155 - } 156 - 157 - return do[types.RepoBranchResponse](us, req) 158 - } 159 - 160 - func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) { 161 - const ( 162 - Method = "GET" 163 - ) 164 - 165 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 166 - 167 - req, err := us.newRequest(Method, endpoint, nil, nil) 168 - if err != nil { 169 - return nil, err 170 - } 171 - 172 - resp, err := us.client.Do(req) 173 - if err != nil { 174 - return nil, err 175 - } 176 - defer resp.Body.Close() 177 - 178 - var defaultBranch types.RepoDefaultBranchResponse 179 - if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil { 180 - return nil, err 181 - } 182 - 183 - return &defaultBranch, nil 184 - } 185 - 186 - func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 187 - const ( 188 - Method = "GET" 189 - Endpoint = "/capabilities" 190 - ) 191 - 192 - req, err := us.newRequest(Method, Endpoint, nil, nil) 193 - if err != nil { 194 - return nil, err 195 - } 196 - 197 - resp, err := us.client.Do(req) 198 - if err != nil { 199 - return nil, err 200 - } 201 - defer resp.Body.Close() 202 - 203 - var capabilities types.Capabilities 204 - if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 205 - return nil, err 206 - } 207 - 208 - return &capabilities, nil 209 - } 210 - 211 - func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 212 - const ( 213 - Method = "GET" 214 - ) 215 - 216 - endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 217 - 218 - req, err := us.newRequest(Method, endpoint, nil, nil) 219 - if err != nil { 220 - return nil, fmt.Errorf("Failed to create request.") 221 - } 222 - 223 - compareResp, err := us.client.Do(req) 224 - if err != nil { 225 - return nil, fmt.Errorf("Failed to create request.") 226 - } 227 - defer compareResp.Body.Close() 228 - 229 - switch compareResp.StatusCode { 230 - case 404: 231 - case 400: 232 - return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 233 - } 234 - 235 - respBody, err := io.ReadAll(compareResp.Body) 236 - if err != nil { 237 - log.Println("failed to compare across branches") 238 - return nil, fmt.Errorf("Failed to compare branches.") 239 - } 240 - defer compareResp.Body.Close() 241 - 242 - var formatPatchResponse types.RepoFormatPatchResponse 243 - err = json.Unmarshal(respBody, &formatPatchResponse) 244 - if err != nil { 245 - log.Println("failed to unmarshal format-patch response", err) 246 - return nil, fmt.Errorf("failed to compare branches.") 247 - } 248 - 249 - return &formatPatchResponse, nil 250 - }
···
+8 -1
knotserver/config/config.go
··· 17 type Server struct { 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 - Secret string `env:"SECRET, required"` 21 DBPath string `env:"DB_PATH, default=knotserver.db"` 22 Hostname string `env:"HOSTNAME, required"` 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 26 // This disables signature verification so use with caution. 27 Dev bool `env:"DEV, default=false"` 28 } 29 30 func (s Server) Did() syntax.DID { 31 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 } ··· 34 type Config struct { 35 Repo Repo `env:",prefix=KNOT_REPO_"` 36 Server Server `env:",prefix=KNOT_SERVER_"` 37 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 38 } 39
··· 17 type Server struct { 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 Hostname string `env:"HOSTNAME, required"` 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 + Owner string `env:"OWNER, required"` 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 26 // This disables signature verification so use with caution. 27 Dev bool `env:"DEV, default=false"` 28 } 29 30 + type Git struct { 31 + // user name & email used as committer 32 + UserName string `env:"USER_NAME, default=Tangled"` 33 + UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"` 34 + } 35 + 36 func (s Server) Did() syntax.DID { 37 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 38 } ··· 40 type Config struct { 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 44 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46
+14 -10
knotserver/db/init.go
··· 2 3 import ( 4 "database/sql" 5 6 _ "github.com/mattn/go-sqlite3" 7 ) ··· 11 } 12 13 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 if err != nil { 16 return nil, err 17 } 18 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 29 create table if not exists known_dids ( 30 did text primary key 31 );
··· 2 3 import ( 4 "database/sql" 5 + "strings" 6 7 _ "github.com/mattn/go-sqlite3" 8 ) ··· 12 } 13 14 func Setup(dbPath string) (*DB, error) { 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, "&")) 24 if err != nil { 25 return nil, err 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. 31 32 + _, err = db.Exec(` 33 create table if not exists known_dids ( 34 did text primary key 35 );
+40
knotserver/db/pubkeys.go
··· 1 package db 2 3 import ( 4 "time" 5 6 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 return keys, nil 101 }
··· 1 package db 2 3 import ( 4 + "strconv" 5 "time" 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 100 101 return keys, nil 102 } 103 + 104 + func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { 105 + var keys []PublicKey 106 + 107 + offset := 0 108 + if cursor != "" { 109 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 110 + offset = o 111 + } 112 + } 113 + 114 + query := `select key, did, created from public_keys order by created desc limit ? offset ?` 115 + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results 116 + if err != nil { 117 + return nil, "", err 118 + } 119 + defer rows.Close() 120 + 121 + for rows.Next() { 122 + var publicKey PublicKey 123 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 124 + return nil, "", err 125 + } 126 + keys = append(keys, publicKey) 127 + } 128 + 129 + if err := rows.Err(); err != nil { 130 + return nil, "", err 131 + } 132 + 133 + // check if there are more results for pagination 134 + var nextCursor string 135 + if len(keys) > limit { 136 + keys = keys[:limit] // remove the extra item 137 + nextCursor = strconv.Itoa(offset + limit) 138 + } 139 + 140 + return keys, nextCursor, nil 141 + }
+2 -2
knotserver/events.go
··· 15 WriteBufferSize: 1024, 16 } 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 19 l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 ··· 83 } 84 } 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 87 events, err := h.db.GetEvents(*cursor) 88 if err != nil { 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
··· 15 WriteBufferSize: 1024, 16 } 17 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 ··· 83 } 84 } 85 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 events, err := h.db.GetEvents(*cursor) 88 if err != nil { 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-56
knotserver/file.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "net/http" 8 - "strings" 9 - 10 - "tangled.sh/tangled.sh/core/types" 11 - ) 12 - 13 - func (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 - func countLines(r io.Reader) (int, error) { 21 - buf := make([]byte, 32*1024) 22 - bufLen := 0 23 - count := 0 24 - nl := []byte{'\n'} 25 - 26 - for { 27 - c, err := r.Read(buf) 28 - if c > 0 { 29 - bufLen += c 30 - } 31 - count += bytes.Count(buf[:c], nl) 32 - 33 - switch { 34 - case err == io.EOF: 35 - /* handle last line not having a newline at the end */ 36 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 37 - count++ 38 - } 39 - return count, nil 40 - case err != nil: 41 - return 0, err 42 - } 43 - } 44 - } 45 - 46 - func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) { 47 - lc, err := countLines(strings.NewReader(resp.Contents)) 48 - if err != nil { 49 - // Non-fatal, we'll just skip showing line numbers in the template. 50 - l.Warn("counting lines", "error", err) 51 - } 52 - 53 - resp.Lines = lc 54 - writeJSON(w, resp) 55 - return 56 - }
···
+8 -10
knotserver/git/fork.go
··· 10 ) 11 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 { 19 return fmt.Errorf("failed to bare clone repository: %w", err) 20 } 21 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 24 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 } 26 27 return nil 28 } 29 30 - func (g *GitRepo) Sync(branch string) error { 31 fetchOpts := &git.FetchOptions{ 32 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 34 }, 35 } 36
··· 10 ) 11 12 func Fork(repoPath, source string) error { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 15 return fmt.Errorf("failed to bare clone repository: %w", err) 16 } 17 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 21 } 22 23 return nil 24 } 25 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 29 fetchOpts := &git.FetchOptions{ 30 RefSpecs: []config.RefSpec{ 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 32 }, 33 } 34
+4 -1
knotserver/git/language.go
··· 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" ··· 20 return nil 21 } 22 23 - if enry.IsGenerated(filepath, content) { 24 return nil 25 } 26
··· 3 import ( 4 "context" 5 "path" 6 + "strings" 7 8 "github.com/go-enry/go-enry/v2" 9 "github.com/go-git/go-git/v5/plumbing/object" ··· 21 return nil 22 } 23 24 + if enry.IsGenerated(filepath, content) || 25 + enry.IsBinary(content) || 26 + strings.HasSuffix(filepath, "bun.lock") { 27 return nil 28 } 29
+58 -72
knotserver/git/merge.go
··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 ) 17 18 type MergeCheckCache struct { ··· 86 87 // MergeOptions specifies the configuration for a merge operation 88 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 94 } 95 96 func (e ErrMerge) Error() string { ··· 143 return tmpDir, nil 144 } 145 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 149 150 - if checkOnly { 151 - cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 152 - } else { 153 - // if patch is a format-patch, apply using 'git am' 154 - if opts.FormatPatch { 155 - amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 156 - amCmd.Stderr = &stderr 157 - if err := amCmd.Run(); err != nil { 158 - return fmt.Errorf("patch application failed: %s", stderr.String()) 159 - } 160 - return nil 161 } 162 - 163 - // else, apply using 'git apply' and commit it manually 164 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 165 - if opts != nil { 166 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 167 - applyCmd.Stderr = &stderr 168 - if err := applyCmd.Run(); err != nil { 169 - return fmt.Errorf("patch application failed: %s", stderr.String()) 170 - } 171 172 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 173 - if err := stageCmd.Run(); err != nil { 174 - return fmt.Errorf("failed to stage changes: %w", err) 175 - } 176 177 - commitArgs := []string{"-C", tmpDir, "commit"} 178 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 182 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 186 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 190 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 194 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 200 201 - cmd = exec.Command("git", commitArgs...) 202 - } else { 203 - // If no commit message specified, use git-am which automatically creates a commit 204 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 205 } 206 } 207 208 cmd.Stderr = &stderr 209 210 if err := cmd.Run(); err != nil { 211 - if checkOnly { 212 - conflicts := parseGitApplyErrors(stderr.String()) 213 - return &ErrMerge{ 214 - Message: "patch cannot be applied cleanly", 215 - Conflicts: conflicts, 216 - HasConflict: len(conflicts) > 0, 217 - OtherError: err, 218 - } 219 - } 220 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 } 222 ··· 227 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 return val 229 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 234 patchFile, err := g.createTempFileWithPatch(patchData) 235 if err != nil { ··· 249 } 250 defer os.RemoveAll(tmpDir) 251 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 253 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 return result 255 } 256 257 - func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 258 - return g.MergeWithOptions(patchData, targetBranch, nil) 259 - } 260 - 261 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error { 262 patchFile, err := g.createTempFileWithPatch(patchData) 263 if err != nil { 264 return &ErrMerge{ ··· 277 } 278 defer os.RemoveAll(tmpDir) 279 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 281 return err 282 } 283
··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 85 86 // MergeOptions specifies the configuration for a merge operation 87 type MergeOptions struct { 88 + CommitMessage string 89 + CommitBody string 90 + AuthorName string 91 + AuthorEmail string 92 + CommitterName string 93 + CommitterEmail string 94 + FormatPatch bool 95 } 96 97 func (e ErrMerge) Error() string { ··· 144 return tmpDir, nil 145 } 146 147 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 148 var stderr bytes.Buffer 149 150 + cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 151 + cmd.Stderr = &stderr 152 + 153 + if err := cmd.Run(); err != nil { 154 + conflicts := parseGitApplyErrors(stderr.String()) 155 + return &ErrMerge{ 156 + Message: "patch cannot be applied cleanly", 157 + Conflicts: conflicts, 158 + HasConflict: len(conflicts) > 0, 159 + OtherError: err, 160 } 161 + } 162 + return nil 163 + } 164 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 + var stderr bytes.Buffer 167 + var cmd *exec.Cmd 168 169 + // configure default git user before merge 170 + exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 + exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 174 + // if patch is a format-patch, apply using 'git am' 175 + if opts.FormatPatch { 176 + cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 + } else { 178 + // else, apply using 'git apply' and commit it manually 179 + applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 + applyCmd.Stderr = &stderr 181 + if err := applyCmd.Run(); err != nil { 182 + return fmt.Errorf("patch application failed: %s", stderr.String()) 183 + } 184 185 + stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 + if err := stageCmd.Run(); err != nil { 187 + return fmt.Errorf("failed to stage changes: %w", err) 188 + } 189 190 + commitArgs := []string{"-C", tmpDir, "commit"} 191 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 195 196 + if authorName != "" && authorEmail != "" { 197 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 + } 199 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 200 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 } 206 + 207 + cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 212 if err := cmd.Run(); err != nil { 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 214 } 215 ··· 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } 223 224 patchFile, err := g.createTempFileWithPatch(patchData) 225 if err != nil { ··· 239 } 240 defer os.RemoveAll(tmpDir) 241 242 + result := g.checkPatch(tmpDir, patchFile) 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 244 return result 245 } 246 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
+28 -22
knotserver/git/post_receive.go
··· 3 import ( 4 "bufio" 5 "context" 6 "fmt" 7 "io" 8 "strings" ··· 57 ByEmail map[string]int 58 } 59 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 66 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 70 71 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 defer cancel() 73 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 77 78 return RefUpdateMeta{ 79 CommitCount: commitCount, 80 IsDefaultRef: isDefaultRef, 81 LangBreakdown: breakdown, 82 - } 83 } 84 85 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 97 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 99 args = append(args, line.NewSha.String()) 100 } else { 101 // git rev-list <oldsha>..<newsha> 102 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String())) ··· 138 } 139 140 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 141 - var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem 142 for e, v := range m.CommitCount.ByEmail { 143 - byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{ 144 Email: e, 145 Count: int64(v), 146 }) 147 } 148 149 - var langs []*tangled.GitRefUpdate_Pair 150 for lang, size := range m.LangBreakdown { 151 - langs = append(langs, &tangled.GitRefUpdate_Pair{ 152 Lang: lang, 153 Size: size, 154 }) 155 } 156 - langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 157 - Inputs: langs, 158 - } 159 160 return tangled.GitRefUpdate_Meta{ 161 - CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 162 ByEmail: byEmail, 163 }, 164 - IsDefaultRef: m.IsDefaultRef, 165 - LangBreakdown: langBreakdown, 166 } 167 }
··· 3 import ( 4 "bufio" 5 "context" 6 + "errors" 7 "fmt" 8 "io" 9 "strings" ··· 58 ByEmail map[string]int 59 } 60 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 64 commitCount, err := g.newCommitCount(line) 65 + errors.Join(errs, err) 66 67 isDefaultRef, err := g.isDefaultBranch(line) 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) 74 75 return RefUpdateMeta{ 76 CommitCount: commitCount, 77 IsDefaultRef: isDefaultRef, 78 LangBreakdown: breakdown, 79 + }, errs 80 } 81 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 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 + } 103 + } 104 + 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())) ··· 145 } 146 147 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 148 + var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount 149 for e, v := range m.CommitCount.ByEmail { 150 + byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{ 151 Email: e, 152 Count: int64(v), 153 }) 154 } 155 156 + var langs []*tangled.GitRefUpdate_IndividualLanguageSize 157 for lang, size := range m.LangBreakdown { 158 + langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{ 159 Lang: lang, 160 Size: size, 161 }) 162 } 163 164 return tangled.GitRefUpdate_Meta{ 165 + CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{ 166 ByEmail: byEmail, 167 }, 168 + IsDefaultRef: m.IsDefaultRef, 169 + LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{ 170 + Inputs: langs, 171 + }, 172 } 173 }
+9 -4
knotserver/git.go
··· 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 } 57 } 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 } 106 } 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 d.RejectPush(w, r, name) 119 } 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 129 // If the appview gave us the repository owner's handle we can attempt to 130 // construct the correct ssh url. 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 hostname := d.c.Server.Hostname 134 if strings.Contains(hostname, ":") { 135 hostname = strings.Split(hostname, ":")[0] 136 } 137 138 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
··· 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 } 57 } 58 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 } 106 } 107 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 d.RejectPush(w, r, name) 119 } 120 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 129 // If the appview gave us the repository owner's handle we can attempt to 130 // construct the correct ssh url. 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 hostname := d.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 141 } 142 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
-211
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - "net/http" 8 - "runtime/debug" 9 - 10 - "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 19 - ) 20 - 21 - type Handle struct { 22 - c *config.Config 23 - db *db.DB 24 - jc *jetstream.JetstreamClient 25 - e *rbac.Enforcer 26 - l *slog.Logger 27 - n *notifier.Notifier 28 - resolver *idresolver.Resolver 29 - 30 - // init is a channel that is closed when the knot has been initailized 31 - // i.e. when the first user (knot owner) has been added. 32 - init chan struct{} 33 - knotInitialized bool 34 - } 35 - 36 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 37 - r := chi.NewRouter() 38 - 39 - h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - resolver: idresolver.DefaultResolver(), 47 - init: make(chan struct{}), 48 - } 49 - 50 - err := e.AddKnot(rbac.ThisServer) 51 - if err != nil { 52 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 - } 54 - 55 - err = h.jc.StartJetstream(ctx, h.processMessages) 56 - if err != nil { 57 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 58 - } 59 - 60 - // Check if the knot knows about any Dids; 61 - // if it does, it is already initialized and we can repopulate the 62 - // Jetstream subscriptions. 63 - dids, err := db.GetAllDids() 64 - if err != nil { 65 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 66 - } 67 - 68 - if len(dids) > 0 { 69 - h.knotInitialized = true 70 - close(h.init) 71 - for _, d := range dids { 72 - h.jc.AddDid(d) 73 - } 74 - } 75 - 76 - r.Get("/", h.Index) 77 - r.Get("/capabilities", h.Capabilities) 78 - r.Get("/version", h.Version) 79 - r.Route("/{did}", func(r chi.Router) { 80 - // Repo routes 81 - r.Route("/{name}", func(r chi.Router) { 82 - r.Route("/collaborator", func(r chi.Router) { 83 - r.Use(h.VerifySignature) 84 - r.Post("/add", h.AddRepoCollaborator) 85 - }) 86 - 87 - r.Route("/languages", func(r chi.Router) { 88 - r.With(h.VerifySignature) 89 - r.Get("/", h.RepoLanguages) 90 - r.Get("/{ref}", h.RepoLanguages) 91 - }) 92 - 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/info/refs", h.InfoRefs) 95 - r.Post("/git-upload-pack", h.UploadPack) 96 - r.Post("/git-receive-pack", h.ReceivePack) 97 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 98 - 99 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 100 - 101 - r.Route("/merge", func(r chi.Router) { 102 - r.With(h.VerifySignature) 103 - r.Post("/", h.Merge) 104 - r.Post("/check", h.MergeCheck) 105 - }) 106 - 107 - r.Route("/tree/{ref}", func(r chi.Router) { 108 - r.Get("/", h.RepoIndex) 109 - r.Get("/*", h.RepoTree) 110 - }) 111 - 112 - r.Route("/blob/{ref}", func(r chi.Router) { 113 - r.Get("/*", h.Blob) 114 - }) 115 - 116 - r.Route("/raw/{ref}", func(r chi.Router) { 117 - r.Get("/*", h.BlobRaw) 118 - }) 119 - 120 - r.Get("/log/{ref}", h.Log) 121 - r.Get("/archive/{file}", h.Archive) 122 - r.Get("/commit/{ref}", h.Diff) 123 - r.Get("/tags", h.Tags) 124 - r.Route("/branches", func(r chi.Router) { 125 - r.Get("/", h.Branches) 126 - r.Get("/{branch}", h.Branch) 127 - r.Route("/default", func(r chi.Router) { 128 - r.Get("/", h.DefaultBranch) 129 - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 130 - }) 131 - }) 132 - }) 133 - }) 134 - 135 - // xrpc apis 136 - r.Mount("/xrpc", h.XrpcRouter()) 137 - 138 - // Create a new repository. 139 - r.Route("/repo", func(r chi.Router) { 140 - r.Use(h.VerifySignature) 141 - r.Put("/new", h.NewRepo) 142 - r.Delete("/", h.RemoveRepo) 143 - r.Route("/fork", func(r chi.Router) { 144 - r.Post("/", h.RepoFork) 145 - r.Post("/sync/{branch}", h.RepoForkSync) 146 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 147 - }) 148 - }) 149 - 150 - r.Route("/member", func(r chi.Router) { 151 - r.Use(h.VerifySignature) 152 - r.Put("/add", h.AddMember) 153 - }) 154 - 155 - // Socket that streams git oplogs 156 - r.Get("/events", h.Events) 157 - 158 - // Initialize the knot with an owner and public key. 159 - r.With(h.VerifySignature).Post("/init", h.Init) 160 - 161 - // Health check. Used for two-way verification with appview. 162 - r.With(h.VerifySignature).Get("/health", h.Health) 163 - 164 - // All public keys on the knot. 165 - r.Get("/keys", h.Keys) 166 - 167 - return r, nil 168 - } 169 - 170 - func (h *Handle) XrpcRouter() http.Handler { 171 - logger := tlog.New("knots") 172 - 173 - xrpc := &xrpc.Xrpc{ 174 - Config: h.c, 175 - Db: h.db, 176 - Ingester: h.jc, 177 - Enforcer: h.e, 178 - Logger: logger, 179 - Notifier: h.n, 180 - Resolver: h.resolver, 181 - } 182 - return xrpc.Router() 183 - } 184 - 185 - // version is set during build time. 186 - var version string 187 - 188 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 189 - if version == "" { 190 - info, ok := debug.ReadBuildInfo() 191 - if !ok { 192 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 193 - return 194 - } 195 - 196 - var modVer string 197 - for _, mod := range info.Deps { 198 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 199 - version = mod.Version 200 - break 201 - } 202 - } 203 - 204 - if modVer == "" { 205 - version = "unknown" 206 - } 207 - } 208 - 209 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 - fmt.Fprintf(w, "knotserver/%s", version) 211 - }
···
-10
knotserver/http_util.go
··· 20 func notFound(w http.ResponseWriter) { 21 writeError(w, "not found", http.StatusNotFound) 22 } 23 - 24 - func writeMsg(w http.ResponseWriter, msg string) { 25 - writeJSON(w, map[string]string{"msg": msg}) 26 - } 27 - 28 - func writeConflict(w http.ResponseWriter, data interface{}) { 29 - w.Header().Set("Content-Type", "application/json") 30 - w.WriteHeader(http.StatusConflict) 31 - json.NewEncoder(w).Encode(data) 32 - }
··· 20 func notFound(w http.ResponseWriter) { 21 writeError(w, "not found", http.StatusNotFound) 22 }
+130 -80
knotserver/ingester.go
··· 8 "net/http" 9 "net/url" 10 "path/filepath" 11 - "slices" 12 "strings" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 25 "tangled.sh/tangled.sh/core/workflow" 26 ) 27 28 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 29 l := log.FromContext(ctx) 30 pk := db.PublicKey{ 31 Did: did, 32 PublicKey: record, ··· 39 return nil 40 } 41 42 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 43 l := log.FromContext(ctx) 44 45 if record.Domain != h.c.Server.Hostname { 46 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 59 } 60 l.Info("added member from firehose", "member", record.Subject) 61 62 - if err := h.db.AddDid(did); err != nil { 63 l.Error("failed to add did", "error", err) 64 return fmt.Errorf("failed to add did: %w", err) 65 } 66 - h.jc.AddDid(did) 67 68 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 69 return fmt.Errorf("failed to fetch and add keys: %w", err) 70 } 71 72 return nil 73 } 74 75 - func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error { 76 l := log.FromContext(ctx) 77 l = l.With("handler", "processPull") 78 l = l.With("did", did) 79 - l = l.With("target_repo", record.TargetRepo) 80 - l = l.With("target_branch", record.TargetBranch) 81 82 - if record.Source == nil { 83 - reason := "not a branch-based pull request" 84 - l.Info("ignoring pull record", "reason", reason) 85 - return fmt.Errorf("ignoring pull record: %s", reason) 86 } 87 88 - if record.Source.Repo != nil { 89 - reason := "fork based pull" 90 - l.Info("ignoring pull record", "reason", reason) 91 - return fmt.Errorf("ignoring pull record: %s", reason) 92 - } 93 94 - allDids, err := h.db.GetAllDids() 95 - if err != nil { 96 - return err 97 } 98 99 - // presently: we only process PRs from collaborators for pipelines 100 - if !slices.Contains(allDids, did) { 101 - reason := "not a known did" 102 - l.Info("rejecting pull record", "reason", reason) 103 - return fmt.Errorf("rejected pull record: %s, %s", reason, did) 104 } 105 106 - repoAt, err := syntax.ParseATURI(record.TargetRepo) 107 if err != nil { 108 - return err 109 } 110 111 // resolve this aturi to extract the repo record ··· 121 122 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 123 if err != nil { 124 - return err 125 } 126 127 repo := resp.Value.Val.(*tangled.Repo) 128 129 if repo.Knot != h.c.Server.Hostname { 130 - reason := "not this knot" 131 - l.Info("rejecting pull record", "reason", reason) 132 - return fmt.Errorf("rejected pull record: %s", reason) 133 } 134 135 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 136 if err != nil { 137 - return err 138 } 139 140 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 141 if err != nil { 142 - return err 143 } 144 145 gr, err := git.Open(repoPath, record.Source.Branch) 146 if err != nil { 147 - return err 148 } 149 150 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 151 if err != nil { 152 - return err 153 } 154 155 - var pipeline workflow.Pipeline 156 for _, e := range workflowDir { 157 if !e.IsFile { 158 continue ··· 164 continue 165 } 166 167 - wf, err := workflow.FromFile(e.Name, contents) 168 - if err != nil { 169 - // TODO: log here, respond to client that is pushing 170 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 171 - continue 172 - } 173 - 174 - pipeline = append(pipeline, wf) 175 } 176 177 trigger := tangled.Pipeline_PullRequestTriggerData{ 178 Action: "create", 179 SourceBranch: record.Source.Branch, 180 SourceSha: record.Source.Sha, 181 - TargetBranch: record.TargetBranch, 182 } 183 184 compiler := workflow.Compiler{ ··· 193 }, 194 } 195 196 - cp := compiler.Compile(pipeline) 197 eventJson, err := json.Marshal(cp) 198 if err != nil { 199 - return err 200 } 201 202 // do not run empty pipelines ··· 204 return nil 205 } 206 207 - event := db.Event{ 208 Rkey: TID(), 209 Nsid: tangled.PipelineNSID, 210 EventJson: string(eventJson), 211 } 212 213 - return h.db.InsertEvent(event, h.n) 214 } 215 216 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 217 l := log.FromContext(ctx) 218 219 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 240 return fmt.Errorf("error reading response body: %w", err) 241 } 242 243 - for _, key := range strings.Split(string(plaintext), "\n") { 244 if key == "" { 245 continue 246 } ··· 256 return nil 257 } 258 259 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 260 - did := event.Did 261 if event.Kind != models.EventKindCommit { 262 return nil 263 } ··· 266 defer func() { 267 eventTime := event.TimeUS 268 lastTimeUs := eventTime + 1 269 - fmt.Println("lastTimeUs", lastTimeUs) 270 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 271 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 272 } 273 }() 274 275 - raw := json.RawMessage(event.Commit.Record) 276 - 277 switch event.Commit.Collection { 278 case tangled.PublicKeyNSID: 279 - var record tangled.PublicKey 280 - if err := json.Unmarshal(raw, &record); err != nil { 281 - return fmt.Errorf("failed to unmarshal record: %w", err) 282 - } 283 - if err := h.processPublicKey(ctx, did, record); err != nil { 284 - return fmt.Errorf("failed to process public key: %w", err) 285 - } 286 - 287 case tangled.KnotMemberNSID: 288 - var record tangled.KnotMember 289 - if err := json.Unmarshal(raw, &record); err != nil { 290 - return fmt.Errorf("failed to unmarshal record: %w", err) 291 - } 292 - if err := h.processKnotMember(ctx, did, record); err != nil { 293 - return fmt.Errorf("failed to process knot member: %w", err) 294 - } 295 case tangled.RepoPullNSID: 296 - var record tangled.RepoPull 297 - if err := json.Unmarshal(raw, &record); err != nil { 298 - return fmt.Errorf("failed to unmarshal record: %w", err) 299 - } 300 - if err := h.processPull(ctx, did, record); err != nil { 301 - return fmt.Errorf("failed to process knot member: %w", err) 302 - } 303 } 304 305 - return err 306 }
··· 8 "net/http" 9 "net/url" 10 "path/filepath" 11 "strings" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 l := log.FromContext(ctx) 29 + raw := json.RawMessage(event.Commit.Record) 30 + did := event.Did 31 + 32 + var record tangled.PublicKey 33 + if err := json.Unmarshal(raw, &record); err != nil { 34 + return fmt.Errorf("failed to unmarshal record: %w", err) 35 + } 36 + 37 pk := db.PublicKey{ 38 Did: did, 39 PublicKey: record, ··· 46 return nil 47 } 48 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 l := log.FromContext(ctx) 51 + raw := json.RawMessage(event.Commit.Record) 52 + did := event.Did 53 + 54 + var record tangled.KnotMember 55 + if err := json.Unmarshal(raw, &record); err != nil { 56 + return fmt.Errorf("failed to unmarshal record: %w", err) 57 + } 58 59 if record.Domain != h.c.Server.Hostname { 60 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 73 } 74 l.Info("added member from firehose", "member", record.Subject) 75 76 + if err := h.db.AddDid(record.Subject); err != nil { 77 l.Error("failed to add did", "error", err) 78 return fmt.Errorf("failed to add did: %w", err) 79 } 80 + h.jc.AddDid(record.Subject) 81 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 84 } 85 86 return nil 87 } 88 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 + raw := json.RawMessage(event.Commit.Record) 91 + did := event.Did 92 + 93 + var record tangled.RepoPull 94 + if err := json.Unmarshal(raw, &record); err != nil { 95 + return fmt.Errorf("failed to unmarshal record: %w", err) 96 + } 97 + 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) 101 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 104 } 105 106 + l = l.With("target_repo", record.Target.Repo) 107 + l = l.With("target_branch", record.Target.Branch) 108 109 + if record.Source == nil { 110 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 111 } 112 113 + if record.Source.Repo != nil { 114 + return fmt.Errorf("ignoring pull record: fork based pull") 115 } 116 117 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 118 if err != nil { 119 + return fmt.Errorf("failed to parse ATURI: %w", err) 120 } 121 122 // resolve this aturi to extract the repo record ··· 132 133 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 134 if err != nil { 135 + return fmt.Errorf("failed to resolver repo: %w", err) 136 } 137 138 repo := resp.Value.Val.(*tangled.Repo) 139 140 if repo.Knot != h.c.Server.Hostname { 141 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 142 } 143 144 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 145 if err != nil { 146 + return fmt.Errorf("failed to construct relative repo path: %w", err) 147 } 148 149 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 150 if err != nil { 151 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 152 } 153 154 gr, err := git.Open(repoPath, record.Source.Branch) 155 if err != nil { 156 + return fmt.Errorf("failed to open git repository: %w", err) 157 } 158 159 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 160 if err != nil { 161 + return fmt.Errorf("failed to open workflow directory: %w", err) 162 } 163 164 + var pipeline workflow.RawPipeline 165 for _, e := range workflowDir { 166 if !e.IsFile { 167 continue ··· 173 continue 174 } 175 176 + pipeline = append(pipeline, workflow.RawWorkflow{ 177 + Name: e.Name, 178 + Contents: contents, 179 + }) 180 } 181 182 trigger := tangled.Pipeline_PullRequestTriggerData{ 183 Action: "create", 184 SourceBranch: record.Source.Branch, 185 SourceSha: record.Source.Sha, 186 + TargetBranch: record.Target.Branch, 187 } 188 189 compiler := workflow.Compiler{ ··· 198 }, 199 } 200 201 + cp := compiler.Compile(compiler.Parse(pipeline)) 202 eventJson, err := json.Marshal(cp) 203 if err != nil { 204 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 205 } 206 207 // do not run empty pipelines ··· 209 return nil 210 } 211 212 + ev := db.Event{ 213 Rkey: TID(), 214 Nsid: tangled.PipelineNSID, 215 EventJson: string(eventJson), 216 } 217 218 + return h.db.InsertEvent(ev, h.n) 219 } 220 221 + // duplicated from add collaborator 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 223 + raw := json.RawMessage(event.Commit.Record) 224 + did := event.Did 225 + 226 + var record tangled.RepoCollaborator 227 + if err := json.Unmarshal(raw, &record); err != nil { 228 + return fmt.Errorf("failed to unmarshal record: %w", err) 229 + } 230 + 231 + repoAt, err := syntax.ParseATURI(record.Repo) 232 + if err != nil { 233 + return err 234 + } 235 + 236 + resolver := idresolver.DefaultResolver() 237 + 238 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 239 + if err != nil || subjectId.Handle.IsInvalidHandle() { 240 + return err 241 + } 242 + 243 + // TODO: fix this for good, we need to fetch the record here unfortunately 244 + // resolve this aturi to extract the repo record 245 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 + if err != nil || owner.Handle.IsInvalidHandle() { 247 + return fmt.Errorf("failed to resolve handle: %w", err) 248 + } 249 + 250 + xrpcc := xrpc.Client{ 251 + Host: owner.PDSEndpoint(), 252 + } 253 + 254 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + repo := resp.Value.Val.(*tangled.Repo) 260 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 261 + 262 + // check perms for this user 263 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 264 + if err != nil { 265 + return fmt.Errorf("failed to check permissions: %w", err) 266 + } 267 + if !ok { 268 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 269 + } 270 + 271 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 272 + return err 273 + } 274 + h.jc.AddDid(subjectId.DID.String()) 275 + 276 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 277 + return err 278 + } 279 + 280 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 281 + } 282 + 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 284 l := log.FromContext(ctx) 285 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 307 return fmt.Errorf("error reading response body: %w", err) 308 } 309 310 + for key := range strings.SplitSeq(string(plaintext), "\n") { 311 if key == "" { 312 continue 313 } ··· 323 return nil 324 } 325 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 327 if event.Kind != models.EventKindCommit { 328 return nil 329 } ··· 332 defer func() { 333 eventTime := event.TimeUS 334 lastTimeUs := eventTime + 1 335 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 336 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 337 } 338 }() 339 340 switch event.Commit.Collection { 341 case tangled.PublicKeyNSID: 342 + err = h.processPublicKey(ctx, event) 343 case tangled.KnotMemberNSID: 344 + err = h.processKnotMember(ctx, event) 345 case tangled.RepoPullNSID: 346 + err = h.processPull(ctx, event) 347 + case tangled.RepoCollaboratorNSID: 348 + err = h.processCollaborator(ctx, event) 349 + } 350 + 351 + if err != nil { 352 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 353 } 354 355 + return nil 356 }
+20 -39
knotserver/internal.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" ··· 46 } 47 48 w.WriteHeader(http.StatusNoContent) 49 - return 50 } 51 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 62 data = append(data, j) 63 } 64 writeJSON(w, data) 65 - return 66 } 67 68 type PushOptions struct { ··· 145 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 } 147 148 - meta := gr.RefUpdateMeta(line) 149 150 metaRecord := meta.AsRecord() 151 ··· 169 EventJson: string(eventJson), 170 } 171 172 - return h.db.InsertEvent(event, h.n) 173 } 174 175 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { ··· 197 return err 198 } 199 200 - pipelineParseErrors := []string{} 201 - 202 - var pipeline workflow.Pipeline 203 for _, e := range workflowDir { 204 if !e.IsFile { 205 continue ··· 211 continue 212 } 213 214 - wf, err := workflow.FromFile(e.Name, contents) 215 - if err != nil { 216 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 217 - pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 218 - continue 219 - } 220 - 221 - pipeline = append(pipeline, wf) 222 } 223 224 trigger := tangled.Pipeline_PushTriggerData{ ··· 239 }, 240 } 241 242 - cp := compiler.Compile(pipeline) 243 eventJson, err := json.Marshal(cp) 244 if err != nil { 245 return err 246 } 247 248 if pushOptions.verboseCi { 249 - hasDiagnostics := false 250 - if len(pipelineParseErrors) > 0 { 251 - hasDiagnostics = true 252 - *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 253 - for _, error := range pipelineParseErrors { 254 - *clientMsgs = append(*clientMsgs, error) 255 - } 256 } 257 - if len(compiler.Diagnostics.Errors) > 0 { 258 - hasDiagnostics = true 259 - *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 260 - for _, error := range compiler.Diagnostics.Errors { 261 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 262 - } 263 - } 264 - if len(compiler.Diagnostics.Warnings) > 0 { 265 - hasDiagnostics = true 266 - *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 267 - for _, warning := range compiler.Diagnostics.Warnings { 268 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 269 - } 270 - } 271 - if !hasDiagnostics { 272 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 273 } 274 } 275
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 "log/slog" 9 "net/http" ··· 47 } 48 49 w.WriteHeader(http.StatusNoContent) 50 } 51 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 62 data = append(data, j) 63 } 64 writeJSON(w, data) 65 } 66 67 type PushOptions struct { ··· 144 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 145 } 146 147 + var errs error 148 + meta, err := gr.RefUpdateMeta(line) 149 + errors.Join(errs, err) 150 151 metaRecord := meta.AsRecord() 152 ··· 170 EventJson: string(eventJson), 171 } 172 173 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 174 } 175 176 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { ··· 198 return err 199 } 200 201 + var pipeline workflow.RawPipeline 202 for _, e := range workflowDir { 203 if !e.IsFile { 204 continue ··· 210 continue 211 } 212 213 + pipeline = append(pipeline, workflow.RawWorkflow{ 214 + Name: e.Name, 215 + Contents: contents, 216 + }) 217 } 218 219 trigger := tangled.Pipeline_PushTriggerData{ ··· 234 }, 235 } 236 237 + cp := compiler.Compile(compiler.Parse(pipeline)) 238 eventJson, err := json.Marshal(cp) 239 if err != nil { 240 return err 241 } 242 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 247 if pushOptions.verboseCi { 248 + if compiler.Diagnostics.IsEmpty() { 249 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 250 } 251 + 252 + for _, w := range compiler.Diagnostics.Warnings { 253 + *clientMsgs = append(*clientMsgs, w.String()) 254 } 255 } 256
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 - "net/http" 8 - "time" 9 - ) 10 - 11 - func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 - if h.c.Server.Dev { 13 - return next 14 - } 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - signature := r.Header.Get("X-Signature") 17 - if signature == "" || !h.verifyHMAC(signature, r) { 18 - writeError(w, "signature verification failed", http.StatusForbidden) 19 - return 20 - } 21 - next.ServeHTTP(w, r) 22 - }) 23 - } 24 - 25 - func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 - secret := h.c.Server.Secret 27 - timestamp := r.Header.Get("X-Timestamp") 28 - if timestamp == "" { 29 - return false 30 - } 31 - 32 - // Verify that the timestamp is not older than a minute 33 - reqTime, err := time.Parse(time.RFC3339, timestamp) 34 - if err != nil { 35 - return false 36 - } 37 - if time.Since(reqTime) > time.Minute { 38 - return false 39 - } 40 - 41 - message := r.Method + r.URL.Path + timestamp 42 - 43 - mac := hmac.New(sha256.New, []byte(secret)) 44 - mac.Write([]byte(message)) 45 - expectedMAC := mac.Sum(nil) 46 - 47 - signatureBytes, err := hex.DecodeString(signature) 48 - if err != nil { 49 - return false 50 - } 51 - 52 - return hmac.Equal(signatureBytes, expectedMAC) 53 - }
···
+152
knotserver/router.go
···
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.sh/tangled.sh/core/idresolver" 11 + "tangled.sh/tangled.sh/core/jetstream" 12 + "tangled.sh/tangled.sh/core/knotserver/config" 13 + "tangled.sh/tangled.sh/core/knotserver/db" 14 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 15 + tlog "tangled.sh/tangled.sh/core/log" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + ) 20 + 21 + type Knot struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 + } 30 + 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 + r := chi.NewRouter() 33 + 34 + h := Knot{ 35 + c: c, 36 + db: db, 37 + e: e, 38 + l: l, 39 + jc: jc, 40 + n: n, 41 + resolver: idresolver.DefaultResolver(), 42 + } 43 + 44 + err := e.AddKnot(rbac.ThisServer) 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 47 + } 48 + 49 + // configure owner 50 + if err = h.configureOwner(); err != nil { 51 + return nil, err 52 + } 53 + h.l.Info("owner set", "did", h.c.Server.Owner) 54 + h.jc.AddDid(h.c.Server.Owner) 55 + 56 + // configure known-dids in jetstream consumer 57 + dids, err := h.db.GetAllDids() 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get all dids: %w", err) 60 + } 61 + for _, d := range dids { 62 + jc.AddDid(d) 63 + } 64 + 65 + err = h.jc.StartJetstream(ctx, h.processMessages) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 + } 69 + 70 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 + }) 73 + 74 + r.Route("/{did}", func(r chi.Router) { 75 + r.Route("/{name}", func(r chi.Router) { 76 + // routes for git operations 77 + r.Get("/info/refs", h.InfoRefs) 78 + r.Post("/git-upload-pack", h.UploadPack) 79 + r.Post("/git-receive-pack", h.ReceivePack) 80 + }) 81 + }) 82 + 83 + // xrpc apis 84 + r.Mount("/xrpc", h.XrpcRouter()) 85 + 86 + // Socket that streams git oplogs 87 + r.Get("/events", h.Events) 88 + 89 + return r, nil 90 + } 91 + 92 + func (h *Knot) XrpcRouter() http.Handler { 93 + logger := tlog.New("knots") 94 + 95 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 + 97 + xrpc := &xrpc.Xrpc{ 98 + Config: h.c, 99 + Db: h.db, 100 + Ingester: h.jc, 101 + Enforcer: h.e, 102 + Logger: logger, 103 + Notifier: h.n, 104 + Resolver: h.resolver, 105 + ServiceAuth: serviceAuth, 106 + } 107 + return xrpc.Router() 108 + } 109 + 110 + func (h *Knot) configureOwner() error { 111 + cfgOwner := h.c.Server.Owner 112 + 113 + rbacDomain := "thisserver" 114 + 115 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 116 + if err != nil { 117 + return err 118 + } 119 + 120 + switch len(existing) { 121 + case 0: 122 + // no owner configured, continue 123 + case 1: 124 + // find existing owner 125 + existingOwner := existing[0] 126 + 127 + // no ownership change, this is okay 128 + if existingOwner == h.c.Server.Owner { 129 + break 130 + } 131 + 132 + // remove existing owner 133 + if err = h.db.RemoveDid(existingOwner); err != nil { 134 + return err 135 + } 136 + if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 137 + return err 138 + } 139 + 140 + default: 141 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 142 + } 143 + 144 + if err = h.db.AddDid(cfgOwner); err != nil { 145 + return fmt.Errorf("failed to add owner to DB: %w", err) 146 + } 147 + if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 148 + return fmt.Errorf("failed to add owner to RBAC: %w", err) 149 + } 150 + 151 + return nil 152 + }
-1348
knotserver/routes.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 - "fmt" 12 - "log" 13 - "net/http" 14 - "net/url" 15 - "os" 16 - "path/filepath" 17 - "strconv" 18 - "strings" 19 - "sync" 20 - "time" 21 - 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 - "github.com/go-chi/chi/v5" 25 - gogit "github.com/go-git/go-git/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 - "github.com/go-git/go-git/v5/plumbing/object" 28 - "tangled.sh/tangled.sh/core/hook" 29 - "tangled.sh/tangled.sh/core/knotserver/db" 30 - "tangled.sh/tangled.sh/core/knotserver/git" 31 - "tangled.sh/tangled.sh/core/patchutil" 32 - "tangled.sh/tangled.sh/core/rbac" 33 - "tangled.sh/tangled.sh/core/types" 34 - ) 35 - 36 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 37 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 38 - } 39 - 40 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 - w.Header().Set("Content-Type", "application/json") 42 - 43 - capabilities := map[string]any{ 44 - "pull_requests": map[string]any{ 45 - "format_patch": true, 46 - "patch_submissions": true, 47 - "branch_submissions": true, 48 - "fork_submissions": true, 49 - }, 50 - } 51 - 52 - jsonData, err := json.Marshal(capabilities) 53 - if err != nil { 54 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 - return 56 - } 57 - 58 - w.Write(jsonData) 59 - } 60 - 61 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 62 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 63 - l := h.l.With("path", path, "handler", "RepoIndex") 64 - ref := chi.URLParam(r, "ref") 65 - ref, _ = url.PathUnescape(ref) 66 - 67 - gr, err := git.Open(path, ref) 68 - if err != nil { 69 - plain, err2 := git.PlainOpen(path) 70 - if err2 != nil { 71 - l.Error("opening repo", "error", err2.Error()) 72 - notFound(w) 73 - return 74 - } 75 - branches, _ := plain.Branches() 76 - 77 - log.Println(err) 78 - 79 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 80 - resp := types.RepoIndexResponse{ 81 - IsEmpty: true, 82 - Branches: branches, 83 - } 84 - writeJSON(w, resp) 85 - return 86 - } else { 87 - l.Error("opening repo", "error", err.Error()) 88 - notFound(w) 89 - return 90 - } 91 - } 92 - 93 - var ( 94 - commits []*object.Commit 95 - total int 96 - branches []types.Branch 97 - files []types.NiceTree 98 - tags []object.Tag 99 - ) 100 - 101 - var wg sync.WaitGroup 102 - errorsCh := make(chan error, 5) 103 - 104 - wg.Add(1) 105 - go func() { 106 - defer wg.Done() 107 - cs, err := gr.Commits(0, 60) 108 - if err != nil { 109 - errorsCh <- fmt.Errorf("commits: %w", err) 110 - return 111 - } 112 - commits = cs 113 - }() 114 - 115 - wg.Add(1) 116 - go func() { 117 - defer wg.Done() 118 - t, err := gr.TotalCommits() 119 - if err != nil { 120 - errorsCh <- fmt.Errorf("calculating total: %w", err) 121 - return 122 - } 123 - total = t 124 - }() 125 - 126 - wg.Add(1) 127 - go func() { 128 - defer wg.Done() 129 - bs, err := gr.Branches() 130 - if err != nil { 131 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 - return 133 - } 134 - branches = bs 135 - }() 136 - 137 - wg.Add(1) 138 - go func() { 139 - defer wg.Done() 140 - ts, err := gr.Tags() 141 - if err != nil { 142 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 - return 144 - } 145 - tags = ts 146 - }() 147 - 148 - wg.Add(1) 149 - go func() { 150 - defer wg.Done() 151 - fs, err := gr.FileTree(r.Context(), "") 152 - if err != nil { 153 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 - return 155 - } 156 - files = fs 157 - }() 158 - 159 - wg.Wait() 160 - close(errorsCh) 161 - 162 - // show any errors 163 - for err := range errorsCh { 164 - l.Error("loading repo", "error", err.Error()) 165 - writeError(w, err.Error(), http.StatusInternalServerError) 166 - return 167 - } 168 - 169 - rtags := []*types.TagReference{} 170 - for _, tag := range tags { 171 - var target *object.Tag 172 - if tag.Target != plumbing.ZeroHash { 173 - target = &tag 174 - } 175 - tr := types.TagReference{ 176 - Tag: target, 177 - } 178 - 179 - tr.Reference = types.Reference{ 180 - Name: tag.Name, 181 - Hash: tag.Hash.String(), 182 - } 183 - 184 - if tag.Message != "" { 185 - tr.Message = tag.Message 186 - } 187 - 188 - rtags = append(rtags, &tr) 189 - } 190 - 191 - var readmeContent string 192 - var readmeFile string 193 - for _, readme := range h.c.Repo.Readme { 194 - content, _ := gr.FileContent(readme) 195 - if len(content) > 0 { 196 - readmeContent = string(content) 197 - readmeFile = readme 198 - } 199 - } 200 - 201 - if ref == "" { 202 - mainBranch, err := gr.FindMainBranch() 203 - if err != nil { 204 - writeError(w, err.Error(), http.StatusInternalServerError) 205 - l.Error("finding main branch", "error", err.Error()) 206 - return 207 - } 208 - ref = mainBranch 209 - } 210 - 211 - resp := types.RepoIndexResponse{ 212 - IsEmpty: false, 213 - Ref: ref, 214 - Commits: commits, 215 - Description: getDescription(path), 216 - Readme: readmeContent, 217 - ReadmeFileName: readmeFile, 218 - Files: files, 219 - Branches: branches, 220 - Tags: rtags, 221 - TotalCommits: total, 222 - } 223 - 224 - writeJSON(w, resp) 225 - return 226 - } 227 - 228 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 229 - treePath := chi.URLParam(r, "*") 230 - ref := chi.URLParam(r, "ref") 231 - ref, _ = url.PathUnescape(ref) 232 - 233 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 234 - 235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 236 - gr, err := git.Open(path, ref) 237 - if err != nil { 238 - notFound(w) 239 - return 240 - } 241 - 242 - files, err := gr.FileTree(r.Context(), treePath) 243 - if err != nil { 244 - writeError(w, err.Error(), http.StatusInternalServerError) 245 - l.Error("file tree", "error", err.Error()) 246 - return 247 - } 248 - 249 - resp := types.RepoTreeResponse{ 250 - Ref: ref, 251 - Parent: treePath, 252 - Description: getDescription(path), 253 - DotDot: filepath.Dir(treePath), 254 - Files: files, 255 - } 256 - 257 - writeJSON(w, resp) 258 - return 259 - } 260 - 261 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 262 - treePath := chi.URLParam(r, "*") 263 - ref := chi.URLParam(r, "ref") 264 - ref, _ = url.PathUnescape(ref) 265 - 266 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 267 - 268 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 269 - gr, err := git.Open(path, ref) 270 - if err != nil { 271 - notFound(w) 272 - return 273 - } 274 - 275 - contents, err := gr.RawContent(treePath) 276 - if err != nil { 277 - writeError(w, err.Error(), http.StatusBadRequest) 278 - l.Error("file content", "error", err.Error()) 279 - return 280 - } 281 - 282 - mimeType := http.DetectContentType(contents) 283 - 284 - // exception for svg 285 - if filepath.Ext(treePath) == ".svg" { 286 - mimeType = "image/svg+xml" 287 - } 288 - 289 - // allow image, video, and text/plain files to be served directly 290 - switch { 291 - case strings.HasPrefix(mimeType, "image/"): 292 - // allowed 293 - case strings.HasPrefix(mimeType, "video/"): 294 - // allowed 295 - case strings.HasPrefix(mimeType, "text/plain"): 296 - // allowed 297 - default: 298 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 300 - return 301 - } 302 - 303 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 304 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 305 - w.Header().Set("Content-Type", mimeType) 306 - w.Write(contents) 307 - } 308 - 309 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 310 - treePath := chi.URLParam(r, "*") 311 - ref := chi.URLParam(r, "ref") 312 - ref, _ = url.PathUnescape(ref) 313 - 314 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 315 - 316 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 317 - gr, err := git.Open(path, ref) 318 - if err != nil { 319 - notFound(w) 320 - return 321 - } 322 - 323 - var isBinaryFile bool = false 324 - contents, err := gr.FileContent(treePath) 325 - if errors.Is(err, git.ErrBinaryFile) { 326 - isBinaryFile = true 327 - } else if errors.Is(err, object.ErrFileNotFound) { 328 - notFound(w) 329 - return 330 - } else if err != nil { 331 - writeError(w, err.Error(), http.StatusInternalServerError) 332 - return 333 - } 334 - 335 - bytes := []byte(contents) 336 - // safe := string(sanitize(bytes)) 337 - sizeHint := len(bytes) 338 - 339 - resp := types.RepoBlobResponse{ 340 - Ref: ref, 341 - Contents: string(bytes), 342 - Path: treePath, 343 - IsBinary: isBinaryFile, 344 - SizeHint: uint64(sizeHint), 345 - } 346 - 347 - h.showFile(resp, w, l) 348 - } 349 - 350 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 351 - name := chi.URLParam(r, "name") 352 - file := chi.URLParam(r, "file") 353 - 354 - l := h.l.With("handler", "Archive", "name", name, "file", file) 355 - 356 - // TODO: extend this to add more files compression (e.g.: xz) 357 - if !strings.HasSuffix(file, ".tar.gz") { 358 - notFound(w) 359 - return 360 - } 361 - 362 - ref := strings.TrimSuffix(file, ".tar.gz") 363 - 364 - // This allows the browser to use a proper name for the file when 365 - // downloading 366 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 367 - setContentDisposition(w, filename) 368 - setGZipMIME(w) 369 - 370 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 371 - gr, err := git.Open(path, ref) 372 - if err != nil { 373 - notFound(w) 374 - return 375 - } 376 - 377 - gw := gzip.NewWriter(w) 378 - defer gw.Close() 379 - 380 - prefix := fmt.Sprintf("%s-%s", name, ref) 381 - err = gr.WriteTar(gw, prefix) 382 - if err != nil { 383 - // once we start writing to the body we can't report error anymore 384 - // so we are only left with printing the error. 385 - l.Error("writing tar file", "error", err.Error()) 386 - return 387 - } 388 - 389 - err = gw.Flush() 390 - if err != nil { 391 - // once we start writing to the body we can't report error anymore 392 - // so we are only left with printing the error. 393 - l.Error("flushing?", "error", err.Error()) 394 - return 395 - } 396 - } 397 - 398 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 399 - ref := chi.URLParam(r, "ref") 400 - ref, _ = url.PathUnescape(ref) 401 - 402 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 403 - 404 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 405 - 406 - gr, err := git.Open(path, ref) 407 - if err != nil { 408 - notFound(w) 409 - return 410 - } 411 - 412 - // Get page parameters 413 - page := 1 414 - pageSize := 30 415 - 416 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 417 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 418 - page = p 419 - } 420 - } 421 - 422 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 423 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 424 - pageSize = ps 425 - } 426 - } 427 - 428 - // convert to offset/limit 429 - offset := (page - 1) * pageSize 430 - limit := pageSize 431 - 432 - commits, err := gr.Commits(offset, limit) 433 - if err != nil { 434 - writeError(w, err.Error(), http.StatusInternalServerError) 435 - l.Error("fetching commits", "error", err.Error()) 436 - return 437 - } 438 - 439 - total := len(commits) 440 - 441 - resp := types.RepoLogResponse{ 442 - Commits: commits, 443 - Ref: ref, 444 - Description: getDescription(path), 445 - Log: true, 446 - Total: total, 447 - Page: page, 448 - PerPage: pageSize, 449 - } 450 - 451 - writeJSON(w, resp) 452 - return 453 - } 454 - 455 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 456 - ref := chi.URLParam(r, "ref") 457 - ref, _ = url.PathUnescape(ref) 458 - 459 - l := h.l.With("handler", "Diff", "ref", ref) 460 - 461 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 462 - gr, err := git.Open(path, ref) 463 - if err != nil { 464 - notFound(w) 465 - return 466 - } 467 - 468 - diff, err := gr.Diff() 469 - if err != nil { 470 - writeError(w, err.Error(), http.StatusInternalServerError) 471 - l.Error("getting diff", "error", err.Error()) 472 - return 473 - } 474 - 475 - resp := types.RepoCommitResponse{ 476 - Ref: ref, 477 - Diff: diff, 478 - } 479 - 480 - writeJSON(w, resp) 481 - return 482 - } 483 - 484 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 485 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 486 - l := h.l.With("handler", "Refs") 487 - 488 - gr, err := git.Open(path, "") 489 - if err != nil { 490 - notFound(w) 491 - return 492 - } 493 - 494 - tags, err := gr.Tags() 495 - if err != nil { 496 - // Non-fatal, we *should* have at least one branch to show. 497 - l.Warn("getting tags", "error", err.Error()) 498 - } 499 - 500 - rtags := []*types.TagReference{} 501 - for _, tag := range tags { 502 - var target *object.Tag 503 - if tag.Target != plumbing.ZeroHash { 504 - target = &tag 505 - } 506 - tr := types.TagReference{ 507 - Tag: target, 508 - } 509 - 510 - tr.Reference = types.Reference{ 511 - Name: tag.Name, 512 - Hash: tag.Hash.String(), 513 - } 514 - 515 - if tag.Message != "" { 516 - tr.Message = tag.Message 517 - } 518 - 519 - rtags = append(rtags, &tr) 520 - } 521 - 522 - resp := types.RepoTagsResponse{ 523 - Tags: rtags, 524 - } 525 - 526 - writeJSON(w, resp) 527 - return 528 - } 529 - 530 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 531 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 532 - 533 - gr, err := git.PlainOpen(path) 534 - if err != nil { 535 - notFound(w) 536 - return 537 - } 538 - 539 - branches, _ := gr.Branches() 540 - 541 - resp := types.RepoBranchesResponse{ 542 - Branches: branches, 543 - } 544 - 545 - writeJSON(w, resp) 546 - return 547 - } 548 - 549 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 550 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 551 - branchName := chi.URLParam(r, "branch") 552 - branchName, _ = url.PathUnescape(branchName) 553 - 554 - l := h.l.With("handler", "Branch") 555 - 556 - gr, err := git.PlainOpen(path) 557 - if err != nil { 558 - notFound(w) 559 - return 560 - } 561 - 562 - ref, err := gr.Branch(branchName) 563 - if err != nil { 564 - l.Error("getting branch", "error", err.Error()) 565 - writeError(w, err.Error(), http.StatusInternalServerError) 566 - return 567 - } 568 - 569 - commit, err := gr.Commit(ref.Hash()) 570 - if err != nil { 571 - l.Error("getting commit object", "error", err.Error()) 572 - writeError(w, err.Error(), http.StatusInternalServerError) 573 - return 574 - } 575 - 576 - defaultBranch, err := gr.FindMainBranch() 577 - isDefault := false 578 - if err != nil { 579 - l.Error("getting default branch", "error", err.Error()) 580 - // do not quit though 581 - } else if defaultBranch == branchName { 582 - isDefault = true 583 - } 584 - 585 - resp := types.RepoBranchResponse{ 586 - Branch: types.Branch{ 587 - Reference: types.Reference{ 588 - Name: ref.Name().Short(), 589 - Hash: ref.Hash().String(), 590 - }, 591 - Commit: commit, 592 - IsDefault: isDefault, 593 - }, 594 - } 595 - 596 - writeJSON(w, resp) 597 - return 598 - } 599 - 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 619 - 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 624 - return 625 - } 626 - 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 630 - } 631 - 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 636 - } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 - } 641 - } 642 - 643 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 644 - l := h.l.With("handler", "NewRepo") 645 - 646 - data := struct { 647 - Did string `json:"did"` 648 - Name string `json:"name"` 649 - DefaultBranch string `json:"default_branch,omitempty"` 650 - }{} 651 - 652 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 653 - writeError(w, "invalid request body", http.StatusBadRequest) 654 - return 655 - } 656 - 657 - if data.DefaultBranch == "" { 658 - data.DefaultBranch = h.c.Repo.MainBranch 659 - } 660 - 661 - did := data.Did 662 - name := data.Name 663 - defaultBranch := data.DefaultBranch 664 - 665 - if err := validateRepoName(name); err != nil { 666 - l.Error("creating repo", "error", err.Error()) 667 - writeError(w, err.Error(), http.StatusBadRequest) 668 - return 669 - } 670 - 671 - relativeRepoPath := filepath.Join(did, name) 672 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 673 - err := git.InitBare(repoPath, defaultBranch) 674 - if err != nil { 675 - l.Error("initializing bare repo", "error", err.Error()) 676 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 677 - writeError(w, "That repo already exists!", http.StatusConflict) 678 - return 679 - } else { 680 - writeError(w, err.Error(), http.StatusInternalServerError) 681 - return 682 - } 683 - } 684 - 685 - // add perms for this user to access the repo 686 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 687 - if err != nil { 688 - l.Error("adding repo permissions", "error", err.Error()) 689 - writeError(w, err.Error(), http.StatusInternalServerError) 690 - return 691 - } 692 - 693 - hook.SetupRepo( 694 - hook.Config( 695 - hook.WithScanPath(h.c.Repo.ScanPath), 696 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 697 - ), 698 - repoPath, 699 - ) 700 - 701 - w.WriteHeader(http.StatusNoContent) 702 - } 703 - 704 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 705 - l := h.l.With("handler", "RepoForkSync") 706 - 707 - data := struct { 708 - Did string `json:"did"` 709 - Source string `json:"source"` 710 - Name string `json:"name,omitempty"` 711 - HiddenRef string `json:"hiddenref"` 712 - }{} 713 - 714 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 715 - writeError(w, "invalid request body", http.StatusBadRequest) 716 - return 717 - } 718 - 719 - did := data.Did 720 - source := data.Source 721 - 722 - if did == "" || source == "" { 723 - l.Error("invalid request body, empty did or name") 724 - w.WriteHeader(http.StatusBadRequest) 725 - return 726 - } 727 - 728 - var name string 729 - if data.Name != "" { 730 - name = data.Name 731 - } else { 732 - name = filepath.Base(source) 733 - } 734 - 735 - branch := chi.URLParam(r, "branch") 736 - branch, _ = url.PathUnescape(branch) 737 - 738 - relativeRepoPath := filepath.Join(did, name) 739 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 740 - 741 - gr, err := git.PlainOpen(repoPath) 742 - if err != nil { 743 - log.Println(err) 744 - notFound(w) 745 - return 746 - } 747 - 748 - forkCommit, err := gr.ResolveRevision(branch) 749 - if err != nil { 750 - l.Error("error resolving ref revision", "msg", err.Error()) 751 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 752 - return 753 - } 754 - 755 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 756 - if err != nil { 757 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 758 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 759 - return 760 - } 761 - 762 - status := types.UpToDate 763 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 764 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 765 - if err != nil { 766 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 767 - return 768 - } 769 - 770 - if isAncestor { 771 - status = types.FastForwardable 772 - } else { 773 - status = types.Conflict 774 - } 775 - } 776 - 777 - w.Header().Set("Content-Type", "application/json") 778 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 779 - } 780 - 781 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 782 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 783 - ref := chi.URLParam(r, "ref") 784 - ref, _ = url.PathUnescape(ref) 785 - 786 - l := h.l.With("handler", "RepoLanguages") 787 - 788 - gr, err := git.Open(repoPath, ref) 789 - if err != nil { 790 - l.Error("opening repo", "error", err.Error()) 791 - notFound(w) 792 - return 793 - } 794 - 795 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 796 - defer cancel() 797 - 798 - sizes, err := gr.AnalyzeLanguages(ctx) 799 - if err != nil { 800 - l.Error("failed to analyze languages", "error", err.Error()) 801 - writeError(w, err.Error(), http.StatusNoContent) 802 - return 803 - } 804 - 805 - resp := types.RepoLanguageResponse{Languages: sizes} 806 - 807 - writeJSON(w, resp) 808 - } 809 - 810 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 811 - l := h.l.With("handler", "RepoForkSync") 812 - 813 - data := struct { 814 - Did string `json:"did"` 815 - Source string `json:"source"` 816 - Name string `json:"name,omitempty"` 817 - }{} 818 - 819 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 820 - writeError(w, "invalid request body", http.StatusBadRequest) 821 - return 822 - } 823 - 824 - did := data.Did 825 - source := data.Source 826 - 827 - if did == "" || source == "" { 828 - l.Error("invalid request body, empty did or name") 829 - w.WriteHeader(http.StatusBadRequest) 830 - return 831 - } 832 - 833 - var name string 834 - if data.Name != "" { 835 - name = data.Name 836 - } else { 837 - name = filepath.Base(source) 838 - } 839 - 840 - branch := chi.URLParam(r, "branch") 841 - branch, _ = url.PathUnescape(branch) 842 - 843 - relativeRepoPath := filepath.Join(did, name) 844 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 845 - 846 - gr, err := git.PlainOpen(repoPath) 847 - if err != nil { 848 - log.Println(err) 849 - notFound(w) 850 - return 851 - } 852 - 853 - err = gr.Sync(branch) 854 - if err != nil { 855 - l.Error("error syncing repo fork", "error", err.Error()) 856 - writeError(w, err.Error(), http.StatusInternalServerError) 857 - return 858 - } 859 - 860 - w.WriteHeader(http.StatusNoContent) 861 - } 862 - 863 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 864 - l := h.l.With("handler", "RepoFork") 865 - 866 - data := struct { 867 - Did string `json:"did"` 868 - Source string `json:"source"` 869 - Name string `json:"name,omitempty"` 870 - }{} 871 - 872 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 873 - writeError(w, "invalid request body", http.StatusBadRequest) 874 - return 875 - } 876 - 877 - did := data.Did 878 - source := data.Source 879 - 880 - if did == "" || source == "" { 881 - l.Error("invalid request body, empty did or name") 882 - w.WriteHeader(http.StatusBadRequest) 883 - return 884 - } 885 - 886 - var name string 887 - if data.Name != "" { 888 - name = data.Name 889 - } else { 890 - name = filepath.Base(source) 891 - } 892 - 893 - relativeRepoPath := filepath.Join(did, name) 894 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 895 - 896 - err := git.Fork(repoPath, source) 897 - if err != nil { 898 - l.Error("forking repo", "error", err.Error()) 899 - writeError(w, err.Error(), http.StatusInternalServerError) 900 - return 901 - } 902 - 903 - // add perms for this user to access the repo 904 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 905 - if err != nil { 906 - l.Error("adding repo permissions", "error", err.Error()) 907 - writeError(w, err.Error(), http.StatusInternalServerError) 908 - return 909 - } 910 - 911 - hook.SetupRepo( 912 - hook.Config( 913 - hook.WithScanPath(h.c.Repo.ScanPath), 914 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 915 - ), 916 - repoPath, 917 - ) 918 - 919 - w.WriteHeader(http.StatusNoContent) 920 - } 921 - 922 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 923 - l := h.l.With("handler", "RemoveRepo") 924 - 925 - data := struct { 926 - Did string `json:"did"` 927 - Name string `json:"name"` 928 - }{} 929 - 930 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 931 - writeError(w, "invalid request body", http.StatusBadRequest) 932 - return 933 - } 934 - 935 - did := data.Did 936 - name := data.Name 937 - 938 - if did == "" || name == "" { 939 - l.Error("invalid request body, empty did or name") 940 - w.WriteHeader(http.StatusBadRequest) 941 - return 942 - } 943 - 944 - relativeRepoPath := filepath.Join(did, name) 945 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 946 - err := os.RemoveAll(repoPath) 947 - if err != nil { 948 - l.Error("removing repo", "error", err.Error()) 949 - writeError(w, err.Error(), http.StatusInternalServerError) 950 - return 951 - } 952 - 953 - w.WriteHeader(http.StatusNoContent) 954 - 955 - } 956 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 957 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 958 - 959 - data := types.MergeRequest{} 960 - 961 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 962 - writeError(w, err.Error(), http.StatusBadRequest) 963 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 964 - return 965 - } 966 - 967 - mo := &git.MergeOptions{ 968 - AuthorName: data.AuthorName, 969 - AuthorEmail: data.AuthorEmail, 970 - CommitBody: data.CommitBody, 971 - CommitMessage: data.CommitMessage, 972 - } 973 - 974 - patch := data.Patch 975 - branch := data.Branch 976 - gr, err := git.Open(path, branch) 977 - if err != nil { 978 - notFound(w) 979 - return 980 - } 981 - 982 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 983 - 984 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 985 - var mergeErr *git.ErrMerge 986 - if errors.As(err, &mergeErr) { 987 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 988 - for i, conflict := range mergeErr.Conflicts { 989 - conflicts[i] = types.ConflictInfo{ 990 - Filename: conflict.Filename, 991 - Reason: conflict.Reason, 992 - } 993 - } 994 - response := types.MergeCheckResponse{ 995 - IsConflicted: true, 996 - Conflicts: conflicts, 997 - Message: mergeErr.Message, 998 - } 999 - writeConflict(w, response) 1000 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1001 - } else { 1002 - writeError(w, err.Error(), http.StatusBadRequest) 1003 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 1004 - } 1005 - return 1006 - } 1007 - 1008 - w.WriteHeader(http.StatusOK) 1009 - } 1010 - 1011 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1012 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1013 - 1014 - var data struct { 1015 - Patch string `json:"patch"` 1016 - Branch string `json:"branch"` 1017 - } 1018 - 1019 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1020 - writeError(w, err.Error(), http.StatusBadRequest) 1021 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1022 - return 1023 - } 1024 - 1025 - patch := data.Patch 1026 - branch := data.Branch 1027 - gr, err := git.Open(path, branch) 1028 - if err != nil { 1029 - notFound(w) 1030 - return 1031 - } 1032 - 1033 - err = gr.MergeCheck([]byte(patch), branch) 1034 - if err == nil { 1035 - response := types.MergeCheckResponse{ 1036 - IsConflicted: false, 1037 - } 1038 - writeJSON(w, response) 1039 - return 1040 - } 1041 - 1042 - var mergeErr *git.ErrMerge 1043 - if errors.As(err, &mergeErr) { 1044 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1045 - for i, conflict := range mergeErr.Conflicts { 1046 - conflicts[i] = types.ConflictInfo{ 1047 - Filename: conflict.Filename, 1048 - Reason: conflict.Reason, 1049 - } 1050 - } 1051 - response := types.MergeCheckResponse{ 1052 - IsConflicted: true, 1053 - Conflicts: conflicts, 1054 - Message: mergeErr.Message, 1055 - } 1056 - writeConflict(w, response) 1057 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1058 - return 1059 - } 1060 - writeError(w, err.Error(), http.StatusInternalServerError) 1061 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1062 - } 1063 - 1064 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1065 - rev1 := chi.URLParam(r, "rev1") 1066 - rev1, _ = url.PathUnescape(rev1) 1067 - 1068 - rev2 := chi.URLParam(r, "rev2") 1069 - rev2, _ = url.PathUnescape(rev2) 1070 - 1071 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1072 - 1073 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1074 - gr, err := git.PlainOpen(path) 1075 - if err != nil { 1076 - notFound(w) 1077 - return 1078 - } 1079 - 1080 - commit1, err := gr.ResolveRevision(rev1) 1081 - if err != nil { 1082 - l.Error("error resolving revision 1", "msg", err.Error()) 1083 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1084 - return 1085 - } 1086 - 1087 - commit2, err := gr.ResolveRevision(rev2) 1088 - if err != nil { 1089 - l.Error("error resolving revision 2", "msg", err.Error()) 1090 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1091 - return 1092 - } 1093 - 1094 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1095 - if err != nil { 1096 - l.Error("error comparing revisions", "msg", err.Error()) 1097 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1098 - return 1099 - } 1100 - 1101 - writeJSON(w, types.RepoFormatPatchResponse{ 1102 - Rev1: commit1.Hash.String(), 1103 - Rev2: commit2.Hash.String(), 1104 - FormatPatch: formatPatch, 1105 - Patch: rawPatch, 1106 - }) 1107 - return 1108 - } 1109 - 1110 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1111 - l := h.l.With("handler", "NewHiddenRef") 1112 - 1113 - forkRef := chi.URLParam(r, "forkRef") 1114 - forkRef, _ = url.PathUnescape(forkRef) 1115 - 1116 - remoteRef := chi.URLParam(r, "remoteRef") 1117 - remoteRef, _ = url.PathUnescape(remoteRef) 1118 - 1119 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1120 - gr, err := git.PlainOpen(path) 1121 - if err != nil { 1122 - notFound(w) 1123 - return 1124 - } 1125 - 1126 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1127 - if err != nil { 1128 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1129 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1130 - return 1131 - } 1132 - 1133 - w.WriteHeader(http.StatusNoContent) 1134 - return 1135 - } 1136 - 1137 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1138 - l := h.l.With("handler", "AddMember") 1139 - 1140 - data := struct { 1141 - Did string `json:"did"` 1142 - }{} 1143 - 1144 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1145 - writeError(w, "invalid request body", http.StatusBadRequest) 1146 - return 1147 - } 1148 - 1149 - did := data.Did 1150 - 1151 - if err := h.db.AddDid(did); err != nil { 1152 - l.Error("adding did", "error", err.Error()) 1153 - writeError(w, err.Error(), http.StatusInternalServerError) 1154 - return 1155 - } 1156 - h.jc.AddDid(did) 1157 - 1158 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1159 - l.Error("adding member", "error", err.Error()) 1160 - writeError(w, err.Error(), http.StatusInternalServerError) 1161 - return 1162 - } 1163 - 1164 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1165 - l.Error("fetching and adding keys", "error", err.Error()) 1166 - writeError(w, err.Error(), http.StatusInternalServerError) 1167 - return 1168 - } 1169 - 1170 - w.WriteHeader(http.StatusNoContent) 1171 - } 1172 - 1173 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1174 - l := h.l.With("handler", "AddRepoCollaborator") 1175 - 1176 - data := struct { 1177 - Did string `json:"did"` 1178 - }{} 1179 - 1180 - ownerDid := chi.URLParam(r, "did") 1181 - repo := chi.URLParam(r, "name") 1182 - 1183 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1184 - writeError(w, "invalid request body", http.StatusBadRequest) 1185 - return 1186 - } 1187 - 1188 - if err := h.db.AddDid(data.Did); err != nil { 1189 - l.Error("adding did", "error", err.Error()) 1190 - writeError(w, err.Error(), http.StatusInternalServerError) 1191 - return 1192 - } 1193 - h.jc.AddDid(data.Did) 1194 - 1195 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1196 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1197 - l.Error("adding repo collaborator", "error", err.Error()) 1198 - writeError(w, err.Error(), http.StatusInternalServerError) 1199 - return 1200 - } 1201 - 1202 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1203 - l.Error("fetching and adding keys", "error", err.Error()) 1204 - writeError(w, err.Error(), http.StatusInternalServerError) 1205 - return 1206 - } 1207 - 1208 - w.WriteHeader(http.StatusNoContent) 1209 - } 1210 - 1211 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1212 - l := h.l.With("handler", "DefaultBranch") 1213 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1214 - 1215 - gr, err := git.Open(path, "") 1216 - if err != nil { 1217 - notFound(w) 1218 - return 1219 - } 1220 - 1221 - branch, err := gr.FindMainBranch() 1222 - if err != nil { 1223 - writeError(w, err.Error(), http.StatusInternalServerError) 1224 - l.Error("getting default branch", "error", err.Error()) 1225 - return 1226 - } 1227 - 1228 - writeJSON(w, types.RepoDefaultBranchResponse{ 1229 - Branch: branch, 1230 - }) 1231 - } 1232 - 1233 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1234 - l := h.l.With("handler", "SetDefaultBranch") 1235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1236 - 1237 - data := struct { 1238 - Branch string `json:"branch"` 1239 - }{} 1240 - 1241 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1242 - writeError(w, err.Error(), http.StatusBadRequest) 1243 - return 1244 - } 1245 - 1246 - gr, err := git.PlainOpen(path) 1247 - if err != nil { 1248 - notFound(w) 1249 - return 1250 - } 1251 - 1252 - err = gr.SetDefaultBranch(data.Branch) 1253 - if err != nil { 1254 - writeError(w, err.Error(), http.StatusInternalServerError) 1255 - l.Error("setting default branch", "error", err.Error()) 1256 - return 1257 - } 1258 - 1259 - w.WriteHeader(http.StatusNoContent) 1260 - } 1261 - 1262 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1263 - l := h.l.With("handler", "Init") 1264 - 1265 - if h.knotInitialized { 1266 - writeError(w, "knot already initialized", http.StatusConflict) 1267 - return 1268 - } 1269 - 1270 - data := struct { 1271 - Did string `json:"did"` 1272 - }{} 1273 - 1274 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1275 - l.Error("failed to decode request body", "error", err.Error()) 1276 - writeError(w, "invalid request body", http.StatusBadRequest) 1277 - return 1278 - } 1279 - 1280 - if data.Did == "" { 1281 - l.Error("empty DID in request", "did", data.Did) 1282 - writeError(w, "did is empty", http.StatusBadRequest) 1283 - return 1284 - } 1285 - 1286 - if err := h.db.AddDid(data.Did); err != nil { 1287 - l.Error("failed to add DID", "error", err.Error()) 1288 - writeError(w, err.Error(), http.StatusInternalServerError) 1289 - return 1290 - } 1291 - h.jc.AddDid(data.Did) 1292 - 1293 - if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1294 - l.Error("adding owner", "error", err.Error()) 1295 - writeError(w, err.Error(), http.StatusInternalServerError) 1296 - return 1297 - } 1298 - 1299 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1300 - l.Error("fetching and adding keys", "error", err.Error()) 1301 - writeError(w, err.Error(), http.StatusInternalServerError) 1302 - return 1303 - } 1304 - 1305 - close(h.init) 1306 - 1307 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1308 - mac.Write([]byte("ok")) 1309 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1310 - 1311 - w.WriteHeader(http.StatusNoContent) 1312 - } 1313 - 1314 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1315 - w.Write([]byte("ok")) 1316 - } 1317 - 1318 - func validateRepoName(name string) error { 1319 - // check for path traversal attempts 1320 - if name == "." || name == ".." || 1321 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1322 - return fmt.Errorf("Repository name contains invalid path characters") 1323 - } 1324 - 1325 - // check for sequences that could be used for traversal when normalized 1326 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1327 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1328 - return fmt.Errorf("Repository name contains invalid path sequence") 1329 - } 1330 - 1331 - // then continue with character validation 1332 - for _, char := range name { 1333 - if !((char >= 'a' && char <= 'z') || 1334 - (char >= 'A' && char <= 'Z') || 1335 - (char >= '0' && char <= '9') || 1336 - char == '-' || char == '_' || char == '.') { 1337 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1338 - } 1339 - } 1340 - 1341 - // additional check to prevent multiple sequential dots 1342 - if strings.Contains(name, "..") { 1343 - return fmt.Errorf("Repository name cannot contain sequential dots") 1344 - } 1345 - 1346 - // if all checks pass 1347 - return nil 1348 - }
···
+17 -13
knotserver/server.go
··· 22 Usage: "run a knot server", 23 Action: Run, 24 Description: ` 25 - Environment variables: 26 - KNOT_SERVER_SECRET (required) 27 - KNOT_SERVER_HOSTNAME (required) 28 - KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 29 - KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 30 - KNOT_SERVER_DB_PATH (default: knotserver.db) 31 - KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 32 - KNOT_SERVER_DEV (default: false) 33 - KNOT_REPO_SCAN_PATH (default: /home/git) 34 - KNOT_REPO_README (comma-separated list) 35 - KNOT_REPO_MAIN_BRANCH (default: main) 36 - APPVIEW_ENDPOINT (default: https://tangled.sh) 37 - `, 38 } 39 } 40 ··· 76 tangled.PublicKeyNSID, 77 tangled.KnotMemberNSID, 78 tangled.RepoPullNSID, 79 }, nil, logger, db, true, c.Server.LogDids) 80 if err != nil { 81 logger.Error("failed to setup jetstream", "error", err)
··· 22 Usage: "run a knot server", 23 Action: Run, 24 Description: ` 25 + Environment variables: 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_HOSTNAME (required) 30 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 31 + KNOT_SERVER_OWNER (required) 32 + KNOT_SERVER_LOG_DIDS (default: true) 33 + KNOT_SERVER_DEV (default: false) 34 + KNOT_REPO_SCAN_PATH (default: /home/git) 35 + KNOT_REPO_README (comma-separated list) 36 + KNOT_REPO_MAIN_BRANCH (default: main) 37 + KNOT_GIT_USER_NAME (default: Tangled) 38 + KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh) 39 + APPVIEW_ENDPOINT (default: https://tangled.sh) 40 + `, 41 } 42 } 43 ··· 79 tangled.PublicKeyNSID, 80 tangled.KnotMemberNSID, 81 tangled.RepoPullNSID, 82 + tangled.RepoCollaboratorNSID, 83 }, nil, logger, db, true, c.Server.LogDids) 84 if err != nil { 85 logger.Error("failed to setup jetstream", "error", err)
+156
knotserver/xrpc/create_repo.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "strings" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + gogit "github.com/go-git/go-git/v5" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 18 + "tangled.sh/tangled.sh/core/knotserver/git" 19 + "tangled.sh/tangled.sh/core/rbac" 20 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 21 + ) 22 + 23 + func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 24 + l := h.Logger.With("handler", "NewRepo") 25 + fail := func(e xrpcerr.XrpcError) { 26 + l.Error("failed", "kind", e.Tag, "error", e.Message) 27 + writeError(w, e, http.StatusBadRequest) 28 + } 29 + 30 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 31 + if !ok { 32 + fail(xrpcerr.MissingActorDidError) 33 + return 34 + } 35 + 36 + isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + if !isMember { 42 + fail(xrpcerr.AccessControlError(actorDid.String())) 43 + return 44 + } 45 + 46 + var data tangled.RepoCreate_Input 47 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + rkey := data.Rkey 53 + 54 + ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) 55 + if err != nil || ident.Handle.IsInvalidHandle() { 56 + fail(xrpcerr.GenericError(err)) 57 + return 58 + } 59 + 60 + xrpcc := xrpc.Client{ 61 + Host: ident.PDSEndpoint(), 62 + } 63 + 64 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(err)) 67 + return 68 + } 69 + 70 + repo := resp.Value.Val.(*tangled.Repo) 71 + 72 + defaultBranch := h.Config.Repo.MainBranch 73 + if data.DefaultBranch != nil && *data.DefaultBranch != "" { 74 + defaultBranch = *data.DefaultBranch 75 + } 76 + 77 + if err := validateRepoName(repo.Name); err != nil { 78 + l.Error("creating repo", "error", err.Error()) 79 + fail(xrpcerr.GenericError(err)) 80 + return 81 + } 82 + 83 + relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) 84 + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 + 86 + if data.Source != nil && *data.Source != "" { 87 + err = git.Fork(repoPath, *data.Source) 88 + if err != nil { 89 + l.Error("forking repo", "error", err.Error()) 90 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 + return 92 + } 93 + } else { 94 + err = git.InitBare(repoPath, defaultBranch) 95 + if err != nil { 96 + l.Error("initializing bare repo", "error", err.Error()) 97 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 98 + fail(xrpcerr.RepoExistsError("repository already exists")) 99 + return 100 + } else { 101 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 + return 103 + } 104 + } 105 + } 106 + 107 + // add perms for this user to access the repo 108 + err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath) 109 + if err != nil { 110 + l.Error("adding repo permissions", "error", err.Error()) 111 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + hook.SetupRepo( 116 + hook.Config( 117 + hook.WithScanPath(h.Config.Repo.ScanPath), 118 + hook.WithInternalApi(h.Config.Server.InternalListenAddr), 119 + ), 120 + repoPath, 121 + ) 122 + 123 + w.WriteHeader(http.StatusOK) 124 + } 125 + 126 + func validateRepoName(name string) error { 127 + // check for path traversal attempts 128 + if name == "." || name == ".." || 129 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 130 + return fmt.Errorf("Repository name contains invalid path characters") 131 + } 132 + 133 + // check for sequences that could be used for traversal when normalized 134 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 135 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 136 + return fmt.Errorf("Repository name contains invalid path sequence") 137 + } 138 + 139 + // then continue with character validation 140 + for _, char := range name { 141 + if !((char >= 'a' && char <= 'z') || 142 + (char >= 'A' && char <= 'Z') || 143 + (char >= '0' && char <= '9') || 144 + char == '-' || char == '_' || char == '.') { 145 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 146 + } 147 + } 148 + 149 + // additional check to prevent multiple sequential dots 150 + if strings.Contains(name, "..") { 151 + return fmt.Errorf("Repository name cannot contain sequential dots") 152 + } 153 + 154 + // if all checks pass 155 + return nil 156 + }
+96
knotserver/xrpc/delete_repo.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/rbac" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "DeleteRepo") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDelete_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + rkey := data.Rkey 41 + 42 + if did == "" || name == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 + return 45 + } 46 + 47 + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 + if err != nil || ident.Handle.IsInvalidHandle() { 49 + fail(xrpcerr.GenericError(err)) 50 + return 51 + } 52 + 53 + xrpcc := xrpc.Client{ 54 + Host: ident.PDSEndpoint(), 55 + } 56 + 57 + // ensure that the record does not exists 58 + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 + if err == nil { 60 + fail(xrpcerr.RecordExistsError(rkey)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + if !isDeleteAllowed { 71 + fail(xrpcerr.AccessControlError(actorDid.String())) 72 + return 73 + } 74 + 75 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = os.RemoveAll(repoPath) 82 + if err != nil { 83 + l.Error("deleting repo", "error", err.Error()) 84 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 + if err != nil { 90 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + w.WriteHeader(http.StatusOK) 96 + }
+111
knotserver/xrpc/fork_status.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/types" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkStatus") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoForkStatus_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + did := data.Did 38 + source := data.Source 39 + branch := data.Branch 40 + hiddenRef := data.HiddenRef 41 + 42 + if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 + return 45 + } 46 + 47 + var name string 48 + if data.Name != "" { 49 + name = data.Name 50 + } else { 51 + name = filepath.Base(source) 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + 56 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + gr, err := git.PlainOpen(repoPath) 69 + if err != nil { 70 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 + return 72 + } 73 + 74 + forkCommit, err := gr.ResolveRevision(branch) 75 + if err != nil { 76 + l.Error("error resolving ref revision", "msg", err.Error()) 77 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 + return 79 + } 80 + 81 + sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 + if err != nil { 83 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 + return 86 + } 87 + 88 + status := types.UpToDate 89 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 + if err != nil { 92 + l.Error("error checking ancestor relationship", "error", err.Error()) 93 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 + return 95 + } 96 + 97 + if isAncestor { 98 + status = types.FastForwardable 99 + } else { 100 + status = types.Conflict 101 + } 102 + } 103 + 104 + response := tangled.RepoForkStatus_Output{ 105 + Status: int64(status), 106 + } 107 + 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(http.StatusOK) 110 + json.NewEncoder(w).Encode(response) 111 + }
+73
knotserver/xrpc/fork_sync.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "ForkSync") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoForkSync_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + did := data.Did 37 + name := data.Name 38 + branch := data.Branch 39 + 40 + if did == "" || name == "" { 41 + fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) 42 + return 43 + } 44 + 45 + relativeRepoPath := filepath.Join(did, name) 46 + 47 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 + return 51 + } 52 + 53 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + gr, err := git.Open(repoPath, branch) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 + return 63 + } 64 + 65 + err = gr.Sync() 66 + if err != nil { 67 + l.Error("error syncing repo fork", "error", err.Error()) 68 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + }
+104
knotserver/xrpc/hidden_ref.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+49
knotserver/xrpc/list_keys.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 12 + cursor := r.URL.Query().Get("cursor") 13 + 14 + limit := 100 // default 15 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 16 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 17 + limit = l 18 + } 19 + } 20 + 21 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 22 + if err != nil { 23 + x.Logger.Error("failed to get public keys", "error", err) 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InternalServerError"), 26 + xrpcerr.WithMessage("failed to retrieve public keys"), 27 + ), http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 32 + for _, key := range keys { 33 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 34 + Did: key.Did, 35 + Key: key.Key, 36 + CreatedAt: key.CreatedAt, 37 + }) 38 + } 39 + 40 + response := tangled.KnotListKeys_Output{ 41 + Keys: publicKeys, 42 + } 43 + 44 + if nextCursor != "" { 45 + response.Cursor = &nextCursor 46 + } 47 + 48 + writeJson(w, response) 49 + }
+114
knotserver/xrpc/merge.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/types" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "Merge") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoMerge_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + 41 + if did == "" || name == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 + return 44 + } 45 + 46 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 + if err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + gr, err := git.Open(repoPath, data.Branch) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 + return 68 + } 69 + 70 + mo := git.MergeOptions{} 71 + if data.AuthorName != nil { 72 + mo.AuthorName = *data.AuthorName 73 + } 74 + if data.AuthorEmail != nil { 75 + mo.AuthorEmail = *data.AuthorEmail 76 + } 77 + if data.CommitBody != nil { 78 + mo.CommitBody = *data.CommitBody 79 + } 80 + if data.CommitMessage != nil { 81 + mo.CommitMessage = *data.CommitMessage 82 + } 83 + 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 86 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 + 88 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 + if err != nil { 90 + var mergeErr *git.ErrMerge 91 + if errors.As(err, &mergeErr) { 92 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 93 + for i, conflict := range mergeErr.Conflicts { 94 + conflicts[i] = types.ConflictInfo{ 95 + Filename: conflict.Filename, 96 + Reason: conflict.Reason, 97 + } 98 + } 99 + 100 + conflictErr := xrpcerr.NewXrpcError( 101 + xrpcerr.WithTag("MergeConflict"), 102 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 103 + ) 104 + writeError(w, conflictErr, http.StatusConflict) 105 + return 106 + } else { 107 + l.Error("failed to merge", "error", err.Error()) 108 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 109 + return 110 + } 111 + } 112 + 113 + w.WriteHeader(http.StatusOK) 114 + }
+87
knotserver/xrpc/merge_check.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger.With("handler", "MergeCheck") 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + 22 + var data tangled.RepoMergeCheck_Input 23 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 24 + fail(xrpcerr.GenericError(err)) 25 + return 26 + } 27 + 28 + did := data.Did 29 + name := data.Name 30 + 31 + if did == "" || name == "" { 32 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 33 + return 34 + } 35 + 36 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + 42 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 43 + if err != nil { 44 + fail(xrpcerr.GenericError(err)) 45 + return 46 + } 47 + 48 + gr, err := git.Open(repoPath, data.Branch) 49 + if err != nil { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 51 + return 52 + } 53 + 54 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 + 56 + response := tangled.RepoMergeCheck_Output{ 57 + Is_conflicted: false, 58 + } 59 + 60 + if err != nil { 61 + var mergeErr *git.ErrMerge 62 + if errors.As(err, &mergeErr) { 63 + response.Is_conflicted = true 64 + 65 + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 66 + for i, conflict := range mergeErr.Conflicts { 67 + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 68 + Filename: conflict.Filename, 69 + Reason: conflict.Reason, 70 + } 71 + } 72 + response.Conflicts = conflicts 73 + 74 + if mergeErr.Message != "" { 75 + response.Message = &mergeErr.Message 76 + } 77 + } else { 78 + response.Is_conflicted = true 79 + errMsg := err.Error() 80 + response.Error = &errMsg 81 + } 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + w.WriteHeader(http.StatusOK) 86 + json.NewEncoder(w).Encode(response) 87 + }
+22
knotserver/xrpc/owner.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/api/tangled" 7 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 + ) 9 + 10 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 11 + owner := x.Config.Server.Owner 12 + if owner == "" { 13 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 14 + return 15 + } 16 + 17 + response := tangled.Owner_Output{ 18 + Owner: owner, 19 + } 20 + 21 + writeJson(w, response) 22 + }
+81
knotserver/xrpc/repo_archive.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + format := r.URL.Query().Get("format") 27 + if format == "" { 28 + format = "tar.gz" // default 29 + } 30 + 31 + prefix := r.URL.Query().Get("prefix") 32 + 33 + if format != "tar.gz" { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("InvalidRequest"), 36 + xrpcerr.WithMessage("only tar.gz format is supported"), 37 + ), http.StatusBadRequest) 38 + return 39 + } 40 + 41 + gr, err := git.Open(repoPath, ref) 42 + if err != nil { 43 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 + return 45 + } 46 + 47 + repoParts := strings.Split(repo, "/") 48 + repoName := repoParts[len(repoParts)-1] 49 + 50 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 51 + 52 + var archivePrefix string 53 + if prefix != "" { 54 + archivePrefix = prefix 55 + } else { 56 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 57 + } 58 + 59 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 60 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 61 + w.Header().Set("Content-Type", "application/gzip") 62 + 63 + gw := gzip.NewWriter(w) 64 + defer gw.Close() 65 + 66 + err = gr.WriteTar(gw, archivePrefix) 67 + if err != nil { 68 + // once we start writing to the body we can't report error anymore 69 + // so we are only left with logging the error 70 + x.Logger.Error("writing tar file", "error", err.Error()) 71 + return 72 + } 73 + 74 + err = gw.Flush() 75 + if err != nil { 76 + // once we start writing to the body we can't report error anymore 77 + // so we are only left with logging the error 78 + x.Logger.Error("flushing", "error", err.Error()) 79 + return 80 + } 81 + }
+143
knotserver/xrpc/repo_blob.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 18 + repo := r.URL.Query().Get("repo") 19 + repoPath, err := x.parseRepoParam(repo) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + ref := r.URL.Query().Get("ref") 26 + // ref can be empty (git.Open handles this) 27 + 28 + treePath := r.URL.Query().Get("path") 29 + if treePath == "" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("missing path parameter"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + raw := r.URL.Query().Get("raw") == "true" 38 + 39 + gr, err := git.Open(repoPath, ref) 40 + if err != nil { 41 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + w.Header().Set("Content-Type", mimeType) 73 + 74 + case strings.HasPrefix(mimeType, "text/"): 75 + w.Header().Set("Cache-Control", "public, no-cache") 76 + // serve all text content as text/plain 77 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 78 + 79 + case isTextualMimeType(mimeType): 80 + // handle textual application types (json, xml, etc.) as text/plain 81 + w.Header().Set("Cache-Control", "public, no-cache") 82 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 83 + 84 + default: 85 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("InvalidRequest"), 88 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 89 + ), http.StatusForbidden) 90 + return 91 + } 92 + w.Write(contents) 93 + return 94 + } 95 + 96 + isTextual := func(mt string) bool { 97 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 98 + } 99 + 100 + var content string 101 + var encoding string 102 + 103 + isBinary := !isTextual(mimeType) 104 + 105 + if isBinary { 106 + content = base64.StdEncoding.EncodeToString(contents) 107 + encoding = "base64" 108 + } else { 109 + content = string(contents) 110 + encoding = "utf-8" 111 + } 112 + 113 + response := tangled.RepoBlob_Output{ 114 + Ref: ref, 115 + Path: treePath, 116 + Content: content, 117 + Encoding: &encoding, 118 + Size: &[]int64{int64(len(contents))}[0], 119 + IsBinary: &isBinary, 120 + } 121 + 122 + if mimeType != "" { 123 + response.MimeType = &mimeType 124 + } 125 + 126 + writeJson(w, response) 127 + } 128 + 129 + // isTextualMimeType returns true if the MIME type represents textual content 130 + // that should be served as text/plain for security reasons 131 + func isTextualMimeType(mimeType string) bool { 132 + textualTypes := []string{ 133 + "application/json", 134 + "application/xml", 135 + "application/yaml", 136 + "application/x-yaml", 137 + "application/toml", 138 + "application/javascript", 139 + "application/ecmascript", 140 + } 141 + 142 + return slices.Contains(textualTypes, mimeType) 143 + }
+85
knotserver/xrpc/repo_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "time" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + ref, err := gr.Branch(branchName) 39 + if err != nil { 40 + x.Logger.Error("getting branch", "error", err.Error()) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("BranchNotFound"), 43 + xrpcerr.WithMessage("branch not found"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + commit, err := gr.Commit(ref.Hash()) 49 + if err != nil { 50 + x.Logger.Error("getting commit object", "error", err.Error()) 51 + writeError(w, xrpcerr.NewXrpcError( 52 + xrpcerr.WithTag("BranchNotFound"), 53 + xrpcerr.WithMessage("failed to get commit object"), 54 + ), http.StatusInternalServerError) 55 + return 56 + } 57 + 58 + defaultBranch, err := gr.FindMainBranch() 59 + isDefault := false 60 + if err != nil { 61 + x.Logger.Error("getting default branch", "error", err.Error()) 62 + } else if defaultBranch == branchName { 63 + isDefault = true 64 + } 65 + 66 + response := tangled.RepoBranch_Output{ 67 + Name: ref.Name().Short(), 68 + Hash: ref.Hash().String(), 69 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 70 + When: commit.Author.When.Format(time.RFC3339), 71 + IsDefault: &isDefault, 72 + } 73 + 74 + if commit.Message != "" { 75 + response.Message = &commit.Message 76 + } 77 + 78 + response.Author = &tangled.RepoBranch_Signature{ 79 + Name: commit.Author.Name, 80 + Email: commit.Author.Email, 81 + When: commit.Author.When.Format(time.RFC3339), 82 + } 83 + 84 + writeJson(w, response) 85 + }
+56
knotserver/xrpc/repo_branches.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + cursor := r.URL.Query().Get("cursor") 21 + 22 + // limit := 50 // default 23 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 + // limit = l 26 + // } 27 + // } 28 + 29 + limit := 500 30 + 31 + gr, err := git.PlainOpen(repoPath) 32 + if err != nil { 33 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 34 + return 35 + } 36 + 37 + branches, _ := gr.Branches() 38 + 39 + offset := 0 40 + if cursor != "" { 41 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 + offset = o 43 + } 44 + } 45 + 46 + end := min(offset+limit, len(branches)) 47 + 48 + paginatedBranches := branches[offset:end] 49 + 50 + // Create response using existing types.RepoBranchesResponse 51 + response := types.RepoBranchesResponse{ 52 + Branches: paginatedBranches, 53 + } 54 + 55 + writeJson(w, response) 56 + }
+82
knotserver/xrpc/repo_compare.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + rev1 := r.URL.Query().Get("rev1") 21 + if rev1 == "" { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("InvalidRequest"), 24 + xrpcerr.WithMessage("missing rev1 parameter"), 25 + ), http.StatusBadRequest) 26 + return 27 + } 28 + 29 + rev2 := r.URL.Query().Get("rev2") 30 + if rev2 == "" { 31 + writeError(w, xrpcerr.NewXrpcError( 32 + xrpcerr.WithTag("InvalidRequest"), 33 + xrpcerr.WithMessage("missing rev2 parameter"), 34 + ), http.StatusBadRequest) 35 + return 36 + } 37 + 38 + gr, err := git.PlainOpen(repoPath) 39 + if err != nil { 40 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 41 + return 42 + } 43 + 44 + commit1, err := gr.ResolveRevision(rev1) 45 + if err != nil { 46 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("RevisionNotFound"), 49 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 50 + ), http.StatusBadRequest) 51 + return 52 + } 53 + 54 + commit2, err := gr.ResolveRevision(rev2) 55 + if err != nil { 56 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 57 + writeError(w, xrpcerr.NewXrpcError( 58 + xrpcerr.WithTag("RevisionNotFound"), 59 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 60 + ), http.StatusBadRequest) 61 + return 62 + } 63 + 64 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 65 + if err != nil { 66 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 67 + writeError(w, xrpcerr.NewXrpcError( 68 + xrpcerr.WithTag("CompareError"), 69 + xrpcerr.WithMessage("error comparing revisions"), 70 + ), http.StatusBadRequest) 71 + return 72 + } 73 + 74 + response := types.RepoFormatPatchResponse{ 75 + Rev1: commit1.Hash.String(), 76 + Rev2: commit2.Hash.String(), 77 + FormatPatch: formatPatch, 78 + Patch: rawPatch, 79 + } 80 + 81 + writeJson(w, response) 82 + }
+41
knotserver/xrpc/repo_diff.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/knotserver/git" 7 + "tangled.sh/tangled.sh/core/types" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 12 + repo := r.URL.Query().Get("repo") 13 + repoPath, err := x.parseRepoParam(repo) 14 + if err != nil { 15 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 16 + return 17 + } 18 + 19 + ref := r.URL.Query().Get("ref") 20 + // ref can be empty (git.Open handles this) 21 + 22 + gr, err := git.Open(repoPath, ref) 23 + if err != nil { 24 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 25 + return 26 + } 27 + 28 + diff, err := gr.Diff() 29 + if err != nil { 30 + x.Logger.Error("getting diff", "error", err.Error()) 31 + writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + response := types.RepoCommitResponse{ 36 + Ref: ref, 37 + Diff: diff, 38 + } 39 + 40 + writeJson(w, response) 41 + }
+39
knotserver/xrpc/repo_get_default_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.PlainOpen(repoPath) 21 + 22 + branch, err := gr.FindMainBranch() 23 + if err != nil { 24 + x.Logger.Error("getting default branch", "error", err.Error()) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("failed to get default branch"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + response := tangled.RepoGetDefaultBranch_Output{ 33 + Name: branch, 34 + Hash: "", 35 + When: time.UnixMicro(0).Format(time.RFC3339), 36 + } 37 + 38 + writeJson(w, response) 39 + }
+76
knotserver/xrpc/repo_languages.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "math" 6 + "net/http" 7 + "time" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + ref := r.URL.Query().Get("ref") 23 + 24 + gr, err := git.Open(repoPath, ref) 25 + if err != nil { 26 + x.Logger.Error("opening repo", "error", err.Error()) 27 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 28 + return 29 + } 30 + 31 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 32 + defer cancel() 33 + 34 + sizes, err := gr.AnalyzeLanguages(ctx) 35 + if err != nil { 36 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 37 + writeError(w, xrpcerr.NewXrpcError( 38 + xrpcerr.WithTag("InvalidRequest"), 39 + xrpcerr.WithMessage("failed to analyze repository languages"), 40 + ), http.StatusNoContent) 41 + return 42 + } 43 + 44 + var apiLanguages []*tangled.RepoLanguages_Language 45 + var totalSize int64 46 + 47 + for _, size := range sizes { 48 + totalSize += size 49 + } 50 + 51 + for name, size := range sizes { 52 + percentagef64 := float64(size) / float64(totalSize) * 100 53 + percentage := math.Round(percentagef64) 54 + 55 + lang := &tangled.RepoLanguages_Language{ 56 + Name: name, 57 + Size: size, 58 + Percentage: int64(percentage), 59 + } 60 + 61 + apiLanguages = append(apiLanguages, lang) 62 + } 63 + 64 + response := tangled.RepoLanguages_Output{ 65 + Ref: ref, 66 + Languages: apiLanguages, 67 + } 68 + 69 + if totalSize > 0 { 70 + response.TotalSize = &totalSize 71 + totalFiles := int64(len(sizes)) 72 + response.TotalFiles = &totalFiles 73 + } 74 + 75 + writeJson(w, response) 76 + }
+81
knotserver/xrpc/repo_log.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + ref := r.URL.Query().Get("ref") 21 + 22 + path := r.URL.Query().Get("path") 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 + return 36 + } 37 + 38 + offset := 0 39 + if cursor != "" { 40 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 41 + offset = o 42 + } 43 + } 44 + 45 + commits, err := gr.Commits(offset, limit) 46 + if err != nil { 47 + x.Logger.Error("fetching commits", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("PathNotFound"), 50 + xrpcerr.WithMessage("failed to read commit log"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + total, err := gr.TotalCommits() 56 + if err != nil { 57 + x.Logger.Error("fetching total commits", "error", err.Error()) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("InternalServerError"), 60 + xrpcerr.WithMessage("failed to fetch total commits"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // Create response using existing types.RepoLogResponse 66 + response := types.RepoLogResponse{ 67 + Commits: commits, 68 + Ref: ref, 69 + Page: (offset / limit) + 1, 70 + PerPage: limit, 71 + Total: total, 72 + } 73 + 74 + if path != "" { 75 + response.Description = path 76 + } 77 + 78 + response.Log = true 79 + 80 + writeJson(w, response) 81 + }
+86
knotserver/xrpc/repo_tags.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + "tangled.sh/tangled.sh/core/types" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + tags, err := gr.Tags() 40 + if err != nil { 41 + x.Logger.Warn("getting tags", "error", err.Error()) 42 + tags = []object.Tag{} 43 + } 44 + 45 + rtags := []*types.TagReference{} 46 + for _, tag := range tags { 47 + var target *object.Tag 48 + if tag.Target != plumbing.ZeroHash { 49 + target = &tag 50 + } 51 + tr := types.TagReference{ 52 + Tag: target, 53 + } 54 + 55 + tr.Reference = types.Reference{ 56 + Name: tag.Name, 57 + Hash: tag.Hash.String(), 58 + } 59 + 60 + if tag.Message != "" { 61 + tr.Message = tag.Message 62 + } 63 + 64 + rtags = append(rtags, &tr) 65 + } 66 + 67 + // apply pagination manually 68 + offset := 0 69 + if cursor != "" { 70 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 + offset = o 72 + } 73 + } 74 + 75 + // calculate end index 76 + end := min(offset+limit, len(rtags)) 77 + 78 + paginatedTags := rtags[offset:end] 79 + 80 + // Create response using existing types.RepoTagsResponse 81 + response := types.RepoTagsResponse{ 82 + Tags: paginatedTags, 83 + } 84 + 85 + writeJson(w, response) 86 + }
+89
knotserver/xrpc/repo_tree.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "path/filepath" 6 + "time" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 14 + ctx := r.Context() 15 + 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + path := r.URL.Query().Get("path") 27 + // path can be empty (defaults to root) 28 + 29 + gr, err := git.Open(repoPath, ref) 30 + if err != nil { 31 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 32 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 33 + return 34 + } 35 + 36 + files, err := gr.FileTree(ctx, path) 37 + if err != nil { 38 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("PathNotFound"), 41 + xrpcerr.WithMessage("failed to read repository tree"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + // convert NiceTree -> tangled.RepoTree_TreeEntry 47 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 + for i, file := range files { 49 + entry := &tangled.RepoTree_TreeEntry{ 50 + Name: file.Name, 51 + Mode: file.Mode, 52 + Size: file.Size, 53 + Is_file: file.IsFile, 54 + Is_subtree: file.IsSubtree, 55 + } 56 + 57 + if file.LastCommit != nil { 58 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 59 + Hash: file.LastCommit.Hash.String(), 60 + Message: file.LastCommit.Message, 61 + When: file.LastCommit.When.Format(time.RFC3339), 62 + } 63 + } 64 + 65 + treeEntries[i] = entry 66 + } 67 + 68 + var parentPtr *string 69 + if path != "" { 70 + parentPtr = &path 71 + } 72 + 73 + var dotdotPtr *string 74 + if path != "" { 75 + dotdot := filepath.Dir(path) 76 + if dotdot != "." { 77 + dotdotPtr = &dotdot 78 + } 79 + } 80 + 81 + response := tangled.RepoTree_Output{ 82 + Ref: ref, 83 + Parent: parentPtr, 84 + Dotdot: dotdotPtr, 85 + Files: treeEntries, 86 + } 87 + 88 + writeJson(w, response) 89 + }
-149
knotserver/xrpc/router.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "log/slog" 8 - "net/http" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/jetstream" 14 - "tangled.sh/tangled.sh/core/knotserver/config" 15 - "tangled.sh/tangled.sh/core/knotserver/db" 16 - "tangled.sh/tangled.sh/core/notifier" 17 - "tangled.sh/tangled.sh/core/rbac" 18 - 19 - "github.com/bluesky-social/indigo/atproto/auth" 20 - "github.com/go-chi/chi/v5" 21 - ) 22 - 23 - type Xrpc struct { 24 - Config *config.Config 25 - Db *db.DB 26 - Ingester *jetstream.JetstreamClient 27 - Enforcer *rbac.Enforcer 28 - Logger *slog.Logger 29 - Notifier *notifier.Notifier 30 - Resolver *idresolver.Resolver 31 - } 32 - 33 - func (x *Xrpc) Router() http.Handler { 34 - r := chi.NewRouter() 35 - 36 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 - 38 - return r 39 - } 40 - 41 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 - l := x.Logger.With("url", r.URL) 44 - 45 - token := r.Header.Get("Authorization") 46 - token = strings.TrimPrefix(token, "Bearer ") 47 - 48 - s := auth.ServiceAuthValidator{ 49 - Audience: x.Config.Server.Did().String(), 50 - Dir: x.Resolver.Directory(), 51 - } 52 - 53 - did, err := s.Validate(r.Context(), token, nil) 54 - if err != nil { 55 - l.Error("signature verification failed", "err", err) 56 - writeError(w, AuthError(err), http.StatusForbidden) 57 - return 58 - } 59 - 60 - r = r.WithContext( 61 - context.WithValue(r.Context(), ActorDid, did), 62 - ) 63 - 64 - next.ServeHTTP(w, r) 65 - }) 66 - } 67 - 68 - type XrpcError struct { 69 - Tag string `json:"error"` 70 - Message string `json:"message"` 71 - } 72 - 73 - func NewXrpcError(opts ...ErrOpt) XrpcError { 74 - x := XrpcError{} 75 - for _, o := range opts { 76 - o(&x) 77 - } 78 - 79 - return x 80 - } 81 - 82 - type ErrOpt = func(xerr *XrpcError) 83 - 84 - func WithTag(tag string) ErrOpt { 85 - return func(xerr *XrpcError) { 86 - xerr.Tag = tag 87 - } 88 - } 89 - 90 - func WithMessage[S ~string](s S) ErrOpt { 91 - return func(xerr *XrpcError) { 92 - xerr.Message = string(s) 93 - } 94 - } 95 - 96 - func WithError(e error) ErrOpt { 97 - return func(xerr *XrpcError) { 98 - xerr.Message = e.Error() 99 - } 100 - } 101 - 102 - var MissingActorDidError = NewXrpcError( 103 - WithTag("MissingActorDid"), 104 - WithMessage("actor DID not supplied"), 105 - ) 106 - 107 - var AuthError = func(err error) XrpcError { 108 - return NewXrpcError( 109 - WithTag("Auth"), 110 - WithError(fmt.Errorf("signature verification failed: %w", err)), 111 - ) 112 - } 113 - 114 - var InvalidRepoError = func(r string) XrpcError { 115 - return NewXrpcError( 116 - WithTag("InvalidRepo"), 117 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 - ) 119 - } 120 - 121 - var AccessControlError = func(d string) XrpcError { 122 - return NewXrpcError( 123 - WithTag("AccessControl"), 124 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 - ) 126 - } 127 - 128 - var GitError = func(e error) XrpcError { 129 - return NewXrpcError( 130 - WithTag("Git"), 131 - WithError(fmt.Errorf("git error: %w", e)), 132 - ) 133 - } 134 - 135 - func GenericError(err error) XrpcError { 136 - return NewXrpcError( 137 - WithTag("Generic"), 138 - WithError(err), 139 - ) 140 - } 141 - 142 - // this is slightly different from http_util::write_error to follow the spec: 143 - // 144 - // the json object returned must include an "error" and a "message" 145 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 - w.Header().Set("Content-Type", "application/json") 147 - w.WriteHeader(status) 148 - json.NewEncoder(w).Encode(e) 149 - }
···
+12 -10
knotserver/xrpc/set_default_branch.go
··· 12 "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
··· 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 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 const ActorDid string = "ActorDid" 20 21 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 22 l := x.Logger 23 + fail := func(e xrpcerr.XrpcError) { 24 l.Error("failed", "kind", e.Tag, "error", e.Message) 25 writeError(w, e, http.StatusBadRequest) 26 } 27 28 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 if !ok { 30 + fail(xrpcerr.MissingActorDidError) 31 return 32 } 33 34 var data tangled.RepoSetDefaultBranch_Input 35 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 36 + fail(xrpcerr.GenericError(err)) 37 return 38 } 39 40 // unfortunately we have to resolve repo-at here 41 repoAt, err := syntax.ParseATURI(data.Repo) 42 if err != nil { 43 + fail(xrpcerr.InvalidRepoError(data.Repo)) 44 return 45 } 46 47 // resolve this aturi to extract the repo record 48 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 49 if err != nil || ident.Handle.IsInvalidHandle() { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 51 return 52 } 53 54 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 55 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 56 if err != nil { 57 + fail(xrpcerr.GenericError(err)) 58 return 59 } 60 61 repo := resp.Value.Val.(*tangled.Repo) 62 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 63 if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 return 66 } 67 68 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 69 l.Error("insufficent permissions", "did", actorDid.String()) 70 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 71 return 72 } 73 74 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 75 gr, err := git.PlainOpen(path) 76 if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 return 79 } 80 81 err = gr.SetDefaultBranch(data.DefaultBranch) 82 if err != nil { 83 l.Error("setting default branch", "error", err.Error()) 84 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 85 return 86 } 87
+60
knotserver/xrpc/version.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "runtime/debug" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + ) 10 + 11 + // version is set during build time. 12 + var version string 13 + 14 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 + if version == "" { 16 + info, ok := debug.ReadBuildInfo() 17 + if !ok { 18 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 19 + return 20 + } 21 + 22 + var modVer string 23 + var sha string 24 + var modified bool 25 + 26 + for _, mod := range info.Deps { 27 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 28 + modVer = mod.Version 29 + break 30 + } 31 + } 32 + 33 + for _, setting := range info.Settings { 34 + switch setting.Key { 35 + case "vcs.revision": 36 + sha = setting.Value 37 + case "vcs.modified": 38 + modified = setting.Value == "true" 39 + } 40 + } 41 + 42 + if modVer == "" { 43 + modVer = "unknown" 44 + } 45 + 46 + if sha == "" { 47 + version = modVer 48 + } else if modified { 49 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 50 + } else { 51 + version = fmt.Sprintf("%s (%s)", modVer, sha) 52 + } 53 + } 54 + 55 + response := tangled.KnotVersion_Output{ 56 + Version: version, 57 + } 58 + 59 + writeJson(w, response) 60 + }
+127
knotserver/xrpc/xrpc.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + "strings" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 14 + "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/notifier" 16 + "tangled.sh/tangled.sh/core/rbac" 17 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + 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 + ServiceAuth *serviceauth.ServiceAuth 32 + } 33 + 34 + func (x *Xrpc) Router() http.Handler { 35 + r := chi.NewRouter() 36 + 37 + r.Group(func(r chi.Router) { 38 + r.Use(x.ServiceAuth.VerifyServiceAuth) 39 + 40 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 44 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 45 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 46 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 47 + }) 48 + 49 + // merge check is an open endpoint 50 + // 51 + // TODO: should we constrain this more? 52 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 53 + // - use ETags on clients to keep requests to a minimum 54 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 55 + 56 + // repo query endpoints (no auth required) 57 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 58 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 59 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 60 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 61 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 62 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 63 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 64 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 65 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 66 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 67 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 68 + 69 + // knot query endpoints (no auth required) 70 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 71 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 72 + 73 + // service query endpoints (no auth required) 74 + r.Get("/"+tangled.OwnerNSID, x.Owner) 75 + 76 + return r 77 + } 78 + 79 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 80 + // the full repository path on disk 81 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 82 + if repo == "" { 83 + return "", xrpcerr.NewXrpcError( 84 + xrpcerr.WithTag("InvalidRequest"), 85 + xrpcerr.WithMessage("missing repo parameter"), 86 + ) 87 + } 88 + 89 + // Parse repo string (did/repoName format) 90 + parts := strings.SplitN(repo, "/", 2) 91 + if len(parts) != 2 { 92 + return "", xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InvalidRequest"), 94 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 95 + ) 96 + } 97 + 98 + did := parts[0] 99 + repoName := parts[1] 100 + 101 + // Construct repository path using the same logic as didPath 102 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 103 + if err != nil { 104 + return "", xrpcerr.RepoNotFoundError 105 + } 106 + 107 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 108 + if err != nil { 109 + return "", xrpcerr.RepoNotFoundError 110 + } 111 + 112 + return repoPath, nil 113 + } 114 + 115 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 116 + w.Header().Set("Content-Type", "application/json") 117 + w.WriteHeader(status) 118 + json.NewEncoder(w).Encode(e) 119 + } 120 + 121 + func writeJson(w http.ResponseWriter, response any) { 122 + w.Header().Set("Content-Type", "application/json") 123 + if err := json.NewEncoder(w).Encode(response); err != nil { 124 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 125 + return 126 + } 127 + }
+158
legal/privacy.md
···
··· 1 + # Privacy Policy 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + This Privacy Policy describes how Tangled ("we," "us," or "our") 6 + collects, uses, and shares your personal information when you use our 7 + platform and services (the "Service"). 8 + 9 + ## 1. Information We Collect 10 + 11 + ### Account Information 12 + 13 + When you create an account, we collect: 14 + 15 + - Your chosen username 16 + - Email address 17 + - Profile information you choose to provide 18 + - Authentication data 19 + 20 + ### Content and Activity 21 + 22 + We store: 23 + 24 + - Code repositories and associated metadata 25 + - Issues, pull requests, and comments 26 + - Activity logs and usage patterns 27 + - Public keys for authentication 28 + 29 + ## 2. Data Location and Hosting 30 + 31 + ### EU Data Hosting 32 + 33 + **All Tangled service data is hosted within the European Union.** 34 + Specifically: 35 + 36 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 + (*.tngl.sh) are located in Finland 38 + - **Application Data:** All other service data is stored on EU-based 39 + servers 40 + - **Data Processing:** All data processing occurs within EU 41 + jurisdiction 42 + 43 + ### External PDS Notice 44 + 45 + **Important:** If your account is hosted on Bluesky's PDS or other 46 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 + that data. The data protection, storage location, and privacy 48 + practices for such accounts are governed by the respective PDS 49 + provider's policies, not this Privacy Policy. We only control data 50 + processing within our own services and infrastructure. 51 + 52 + ## 3. Third-Party Data Processors 53 + 54 + We only share your data with the following third-party processors: 55 + 56 + ### Resend (Email Services) 57 + 58 + - **Purpose:** Sending transactional emails (account verification, 59 + notifications) 60 + - **Data Shared:** Email address and necessary message content 61 + 62 + ### Cloudflare (Image Caching) 63 + 64 + - **Purpose:** Caching and optimizing image delivery 65 + - **Data Shared:** Public images and associated metadata for caching 66 + purposes 67 + 68 + ### Posthog (Usage Metrics Tracking) 69 + 70 + - **Purpose:** Tracking usage and platform metrics 71 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 + information 73 + 74 + ## 4. How We Use Your Information 75 + 76 + We use your information to: 77 + 78 + - Provide and maintain the Service 79 + - Process your transactions and requests 80 + - Send you technical notices and support messages 81 + - Improve and develop new features 82 + - Ensure security and prevent fraud 83 + - Comply with legal obligations 84 + 85 + ## 5. Data Sharing and Disclosure 86 + 87 + We do not sell, trade, or rent your personal information. We may share 88 + your information only in the following circumstances: 89 + 90 + - With the third-party processors listed above 91 + - When required by law or legal process 92 + - To protect our rights, property, or safety, or that of our users 93 + - In connection with a merger, acquisition, or sale of assets (with 94 + appropriate protections) 95 + 96 + ## 6. Data Security 97 + 98 + We implement appropriate technical and organizational measures to 99 + protect your personal information against unauthorized access, 100 + alteration, disclosure, or destruction. However, no method of 101 + transmission over the Internet is 100% secure. 102 + 103 + ## 7. Data Retention 104 + 105 + We retain your personal information for as long as necessary to provide 106 + the Service and fulfill the purposes outlined in this Privacy Policy, 107 + unless a longer retention period is required by law. 108 + 109 + ## 8. Your Rights 110 + 111 + Under applicable data protection laws, you have the right to: 112 + 113 + - Access your personal information 114 + - Correct inaccurate information 115 + - Request deletion of your information 116 + - Object to processing of your information 117 + - Data portability 118 + - Withdraw consent (where applicable) 119 + 120 + ## 9. Cookies and Tracking 121 + 122 + We use cookies and similar technologies to: 123 + 124 + - Maintain your login session 125 + - Remember your preferences 126 + - Analyze usage patterns to improve the Service 127 + 128 + You can control cookie settings through your browser preferences. 129 + 130 + ## 10. Children's Privacy 131 + 132 + The Service is not intended for children under 16 years of age. We do 133 + not knowingly collect personal information from children under 16. If 134 + we become aware that we have collected such information, we will take 135 + steps to delete it. 136 + 137 + ## 11. International Data Transfers 138 + 139 + While all our primary data processing occurs within the EU, some of our 140 + third-party processors may process data outside the EU. When this 141 + occurs, we ensure appropriate safeguards are in place, such as Standard 142 + Contractual Clauses or adequacy decisions. 143 + 144 + ## 12. Changes to This Privacy Policy 145 + 146 + We may update this Privacy Policy from time to time. We will notify you 147 + of any changes by posting the new Privacy Policy on this page and 148 + updating the "Last updated" date. 149 + 150 + ## 13. Contact Information 151 + 152 + If you have any questions about this Privacy Policy or wish to exercise 153 + your rights, please contact us through our platform or via email. 154 + 155 + --- 156 + 157 + This Privacy Policy complies with the EU General Data Protection 158 + Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
···
··· 1 + # Terms of Service 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 + to and use of the Tangled platform and services (the "Service") 7 + operated by us ("Tangled," "we," "us," or "our"). 8 + 9 + ## 1. Acceptance of Terms 10 + 11 + By accessing or using our Service, you agree to be bound by these Terms. 12 + If you disagree with any part of these terms, then you may not access 13 + the Service. 14 + 15 + ## 2. Account Registration 16 + 17 + To use certain features of the Service, you must register for an 18 + account. You agree to provide accurate, current, and complete 19 + information during the registration process and to update such 20 + information to keep it accurate, current, and complete. 21 + 22 + ## 3. Account Termination 23 + 24 + > **Important Notice** 25 + > 26 + > **We reserve the right to terminate, suspend, or restrict access to 27 + > your account at any time, for any reason, or for no reason at all, at 28 + > our sole discretion.** This includes, but is not limited to, 29 + > termination for violation of these Terms, inappropriate conduct, spam, 30 + > abuse, or any other behavior we deem harmful to the Service or other 31 + > users. 32 + > 33 + > Account termination may result in the loss of access to your 34 + > repositories, data, and other content associated with your account. We 35 + > are not obligated to provide advance notice of termination, though we 36 + > may do so in our discretion. 37 + 38 + ## 4. Acceptable Use 39 + 40 + You agree not to use the Service to: 41 + 42 + - Violate any applicable laws or regulations 43 + - Infringe upon the rights of others 44 + - Upload, store, or share content that is illegal, harmful, threatening, 45 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 + objectionable 47 + - Engage in spam, phishing, or other deceptive practices 48 + - Attempt to gain unauthorized access to the Service or other users' 49 + accounts 50 + - Interfere with or disrupt the Service or servers connected to the 51 + Service 52 + 53 + ## 5. Content and Intellectual Property 54 + 55 + You retain ownership of the content you upload to the Service. By 56 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 + license to use, reproduce, modify, and distribute your content as 58 + necessary to provide the Service. 59 + 60 + ## 6. Privacy 61 + 62 + Your privacy is important to us. Please review our [Privacy 63 + Policy](/privacy), which also governs your use of the Service. 64 + 65 + ## 7. Disclaimers 66 + 67 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 + no warranties, expressed or implied, and hereby disclaim and negate all 69 + other warranties including without limitation, implied warranties or 70 + conditions of merchantability, fitness for a particular purpose, or 71 + non-infringement of intellectual property or other violation of rights. 72 + 73 + ## 8. Limitation of Liability 74 + 75 + In no event shall Tangled, nor its directors, employees, partners, 76 + agents, suppliers, or affiliates, be liable for any indirect, 77 + incidental, special, consequential, or punitive damages, including 78 + without limitation, loss of profits, data, use, goodwill, or other 79 + intangible losses, resulting from your use of the Service. 80 + 81 + ## 9. Indemnification 82 + 83 + You agree to defend, indemnify, and hold harmless Tangled and its 84 + affiliates, officers, directors, employees, and agents from and against 85 + any and all claims, damages, obligations, losses, liabilities, costs, 86 + or debt, and expenses (including attorney's fees). 87 + 88 + ## 10. Governing Law 89 + 90 + These Terms shall be interpreted and governed by the laws of Finland, 91 + without regard to its conflict of law provisions. 92 + 93 + ## 11. Changes to Terms 94 + 95 + We reserve the right to modify or replace these Terms at any time. If a 96 + revision is material, we will try to provide at least 30 days notice 97 + prior to any new terms taking effect. 98 + 99 + ## 12. Contact Information 100 + 101 + If you have any questions about these Terms of Service, please contact 102 + us through our platform or via email. 103 + 104 + --- 105 + 106 + These terms are effective as of the last updated date shown above and 107 + will remain in effect except with respect to any changes in their 108 + provisions in the future, which will be in effect immediately after 109 + being posted on this page.
-37
lexicons/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/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 - }
···
-29
lexicons/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 - }
···
+59 -52
lexicons/git/refUpdate.json
··· 51 "maxLength": 40 52 }, 53 "meta": { 54 - "type": "object", 55 - "required": [ 56 - "isDefaultRef", 57 - "commitCount" 58 - ], 59 - "properties": { 60 - "isDefaultRef": { 61 - "type": "boolean", 62 - "default": "false" 63 - }, 64 - "langBreakdown": { 65 - "type": "object", 66 - "properties": { 67 - "inputs": { 68 - "type": "array", 69 - "items": { 70 - "type": "ref", 71 - "ref": "#pair" 72 - } 73 - } 74 - } 75 - }, 76 - "commitCount": { 77 - "type": "object", 78 - "required": [], 79 - "properties": { 80 - "byEmail": { 81 - "type": "array", 82 - "items": { 83 - "type": "object", 84 - "required": [ 85 - "email", 86 - "count" 87 - ], 88 - "properties": { 89 - "email": { 90 - "type": "string" 91 - }, 92 - "count": { 93 - "type": "integer" 94 - } 95 - } 96 - } 97 - } 98 - } 99 - } 100 - } 101 } 102 } 103 } 104 }, 105 - "pair": { 106 "type": "object", 107 - "required": [ 108 - "lang", 109 - "size" 110 - ], 111 "properties": { 112 "lang": { 113 "type": "string" 114 }, 115 "size": { 116 "type": "integer" 117 } 118 }
··· 51 "maxLength": 40 52 }, 53 "meta": { 54 + "type": "ref", 55 + "ref": "#meta" 56 + } 57 + } 58 + } 59 + }, 60 + "meta": { 61 + "type": "object", 62 + "required": ["isDefaultRef", "commitCount"], 63 + "properties": { 64 + "isDefaultRef": { 65 + "type": "boolean", 66 + "default": false 67 + }, 68 + "langBreakdown": { 69 + "type": "ref", 70 + "ref": "#langBreakdown" 71 + }, 72 + "commitCount": { 73 + "type": "ref", 74 + "ref": "#commitCountBreakdown" 75 + } 76 + } 77 + }, 78 + "langBreakdown": { 79 + "type": "object", 80 + "properties": { 81 + "inputs": { 82 + "type": "array", 83 + "items": { 84 + "type": "ref", 85 + "ref": "#individualLanguageSize" 86 } 87 } 88 } 89 }, 90 + "individualLanguageSize": { 91 "type": "object", 92 + "required": ["lang", "size"], 93 "properties": { 94 "lang": { 95 "type": "string" 96 }, 97 "size": { 98 + "type": "integer" 99 + } 100 + } 101 + }, 102 + "commitCountBreakdown": { 103 + "type": "object", 104 + "required": [], 105 + "properties": { 106 + "byEmail": { 107 + "type": "array", 108 + "items": { 109 + "type": "ref", 110 + "ref": "#individualEmailCommitCount" 111 + } 112 + } 113 + } 114 + }, 115 + "individualEmailCommitCount": { 116 + "type": "object", 117 + "required": ["email", "count"], 118 + "properties": { 119 + "email": { 120 + "type": "string" 121 + }, 122 + "count": { 123 "type": "integer" 124 } 125 }
+4 -11
lexicons/issue/comment.json
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 "body": { 34 "type": "string" 35 }, 36 "createdAt": { 37 "type": "string", 38 "format": "datetime" 39 } 40 } 41 }
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "body": { 23 "type": "string" 24 }, 25 "createdAt": { 26 "type": "string", 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 } 33 } 34 }
+1 -14
lexicons/issue/issue.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 19 "properties": { 20 "repo": { 21 "type": "string", 22 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 - }, 27 - "owner": { 28 - "type": "string", 29 - "format": "did" 30 }, 31 "title": { 32 "type": "string"
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": ["repo", "title", "createdAt"], 13 "properties": { 14 "repo": { 15 "type": "string", 16 "format": "at-uri" 17 }, 18 "title": { 19 "type": "string"
+24
lexicons/knot/knot.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+73
lexicons/knot/listKeys.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.listKeys", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List all public keys stored in the knot server", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "Maximum number of keys to return", 14 + "minimum": 1, 15 + "maximum": 1000, 16 + "default": 100 17 + }, 18 + "cursor": { 19 + "type": "string", 20 + "description": "Pagination cursor" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["keys"], 29 + "properties": { 30 + "keys": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "#publicKey" 35 + } 36 + }, 37 + "cursor": { 38 + "type": "string", 39 + "description": "Pagination cursor for next page" 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InternalServerError", 47 + "description": "Failed to retrieve public keys" 48 + } 49 + ] 50 + }, 51 + "publicKey": { 52 + "type": "object", 53 + "required": ["did", "key", "createdAt"], 54 + "properties": { 55 + "did": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID associated with the public key" 59 + }, 60 + "key": { 61 + "type": "string", 62 + "maxLength": 4096, 63 + "description": "Public key contents" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "Key upload timestamp" 69 + } 70 + } 71 + } 72 + } 73 + }
+25
lexicons/knot/version.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.version", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the version of a knot", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "version" 14 + ], 15 + "properties": { 16 + "version": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "errors": [] 23 + } 24 + } 25 + }
-67
lexicons/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/owner.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.owner", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the owner of a service", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "owner" 14 + ], 15 + "properties": { 16 + "owner": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "OwnerNotFound", 26 + "description": "Owner is not set for this service" 27 + } 28 + ] 29 + } 30 + } 31 + }
+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 - }
···
-11
lexicons/pulls/comment.json
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 "body": { 34 "type": "string" 35 },
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "body": { 23 "type": "string" 24 },
+20 -12
lexicons/pulls/pull.json
··· 10 "record": { 11 "type": "object", 12 "required": [ 13 - "targetRepo", 14 - "targetBranch", 15 - "pullId", 16 "title", 17 "patch", 18 "createdAt" 19 ], 20 "properties": { 21 - "targetRepo": { 22 - "type": "string", 23 - "format": "at-uri" 24 - }, 25 - "targetBranch": { 26 - "type": "string" 27 - }, 28 - "pullId": { 29 - "type": "integer" 30 }, 31 "title": { 32 "type": "string" ··· 45 "type": "string", 46 "format": "datetime" 47 } 48 } 49 } 50 },
··· 10 "record": { 11 "type": "object", 12 "required": [ 13 + "target", 14 "title", 15 "patch", 16 "createdAt" 17 ], 18 "properties": { 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 22 }, 23 "title": { 24 "type": "string" ··· 37 "type": "string", 38 "format": "datetime" 39 } 40 + } 41 + } 42 + }, 43 + "target": { 44 + "type": "object", 45 + "required": [ 46 + "repo", 47 + "branch" 48 + ], 49 + "properties": { 50 + "repo": { 51 + "type": "string", 52 + "format": "at-uri" 53 + }, 54 + "branch": { 55 + "type": "string" 56 } 57 } 58 },
-31
lexicons/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 - }
···
+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 + }
+55
lexicons/repo/archive.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.archive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "format": { 20 + "type": "string", 21 + "description": "Archive format", 22 + "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 23 + "default": "tar.gz" 24 + }, 25 + "prefix": { 26 + "type": "string", 27 + "description": "Prefix for files in the archive" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "*/*", 33 + "description": "Binary archive data" 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "RefNotFound", 42 + "description": "Git reference not found" 43 + }, 44 + { 45 + "name": "InvalidRequest", 46 + "description": "Invalid request parameters" 47 + }, 48 + { 49 + "name": "ArchiveError", 50 + "description": "Failed to create archive" 51 + } 52 + ] 53 + } 54 + } 55 + }
+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 + }
+138
lexicons/repo/blob.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.blob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref", "path"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to the file within the repository" 22 + }, 23 + "raw": { 24 + "type": "boolean", 25 + "description": "Return raw file content instead of JSON response", 26 + "default": false 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["ref", "path", "content"], 35 + "properties": { 36 + "ref": { 37 + "type": "string", 38 + "description": "The git reference used" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "The file path" 43 + }, 44 + "content": { 45 + "type": "string", 46 + "description": "File content (base64 encoded for binary files)" 47 + }, 48 + "encoding": { 49 + "type": "string", 50 + "description": "Content encoding", 51 + "enum": ["utf-8", "base64"] 52 + }, 53 + "size": { 54 + "type": "integer", 55 + "description": "File size in bytes" 56 + }, 57 + "isBinary": { 58 + "type": "boolean", 59 + "description": "Whether the file is binary" 60 + }, 61 + "mimeType": { 62 + "type": "string", 63 + "description": "MIME type of the file" 64 + }, 65 + "lastCommit": { 66 + "type": "ref", 67 + "ref": "#lastCommit" 68 + } 69 + } 70 + } 71 + }, 72 + "errors": [ 73 + { 74 + "name": "RepoNotFound", 75 + "description": "Repository not found or access denied" 76 + }, 77 + { 78 + "name": "RefNotFound", 79 + "description": "Git reference not found" 80 + }, 81 + { 82 + "name": "FileNotFound", 83 + "description": "File not found at the specified path" 84 + }, 85 + { 86 + "name": "InvalidRequest", 87 + "description": "Invalid request parameters" 88 + } 89 + ] 90 + }, 91 + "lastCommit": { 92 + "type": "object", 93 + "required": ["hash", "message", "when"], 94 + "properties": { 95 + "hash": { 96 + "type": "string", 97 + "description": "Commit hash" 98 + }, 99 + "shortHash": { 100 + "type": "string", 101 + "description": "Short commit hash" 102 + }, 103 + "message": { 104 + "type": "string", 105 + "description": "Commit message" 106 + }, 107 + "author": { 108 + "type": "ref", 109 + "ref": "#signature" 110 + }, 111 + "when": { 112 + "type": "string", 113 + "format": "datetime", 114 + "description": "Commit timestamp" 115 + } 116 + } 117 + }, 118 + "signature": { 119 + "type": "object", 120 + "required": ["name", "email", "when"], 121 + "properties": { 122 + "name": { 123 + "type": "string", 124 + "description": "Author name" 125 + }, 126 + "email": { 127 + "type": "string", 128 + "description": "Author email" 129 + }, 130 + "when": { 131 + "type": "string", 132 + "format": "datetime", 133 + "description": "Author timestamp" 134 + } 135 + } 136 + } 137 + } 138 + }
+94
lexicons/repo/branch.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "name"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "name": { 16 + "type": "string", 17 + "description": "Branch name to get information for" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["name", "hash", "when"], 26 + "properties": { 27 + "name": { 28 + "type": "string", 29 + "description": "Branch name" 30 + }, 31 + "hash": { 32 + "type": "string", 33 + "description": "Latest commit hash on this branch" 34 + }, 35 + "shortHash": { 36 + "type": "string", 37 + "description": "Short commit hash" 38 + }, 39 + "when": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Timestamp of latest commit" 43 + }, 44 + "message": { 45 + "type": "string", 46 + "description": "Latest commit message" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "#signature" 51 + }, 52 + "isDefault": { 53 + "type": "boolean", 54 + "description": "Whether this is the default branch" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "BranchNotFound", 66 + "description": "Branch not found" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "signature": { 75 + "type": "object", 76 + "required": ["name", "email", "when"], 77 + "properties": { 78 + "name": { 79 + "type": "string", 80 + "description": "Author name" 81 + }, 82 + "email": { 83 + "type": "string", 84 + "description": "Author email" 85 + }, 86 + "when": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "Author timestamp" 90 + } 91 + } 92 + } 93 + } 94 + }
+43
lexicons/repo/branches.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branches", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of branches to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+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 +
+49
lexicons/repo/compare.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.compare", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "rev1", "rev2"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "rev1": { 16 + "type": "string", 17 + "description": "First revision (commit, branch, or tag)" 18 + }, 19 + "rev2": { 20 + "type": "string", 21 + "description": "Second revision (commit, branch, or tag)" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "*/*", 27 + "description": "Compare output in application/json" 28 + }, 29 + "errors": [ 30 + { 31 + "name": "RepoNotFound", 32 + "description": "Repository not found or access denied" 33 + }, 34 + { 35 + "name": "RevisionNotFound", 36 + "description": "One or both revisions not found" 37 + }, 38 + { 39 + "name": "InvalidRequest", 40 + "description": "Invalid request parameters" 41 + }, 42 + { 43 + "name": "CompareError", 44 + "description": "Failed to compare revisions" 45 + } 46 + ] 47 + } 48 + } 49 + }
+33
lexicons/repo/create.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+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 + }
+32
lexicons/repo/delete.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "rkey"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+40
lexicons/repo/diff.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.diff", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "*/*" 23 + }, 24 + "errors": [ 25 + { 26 + "name": "RepoNotFound", 27 + "description": "Repository not found or access denied" 28 + }, 29 + { 30 + "name": "RefNotFound", 31 + "description": "Git reference not found" 32 + }, 33 + { 34 + "name": "InvalidRequest", 35 + "description": "Invalid request parameters" 36 + } 37 + ] 38 + } 39 + } 40 + }
+53
lexicons/repo/forkStatus.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkStatus", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check fork status relative to upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "source", "branch", "hiddenRef"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the fork owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the forked repository" 22 + }, 23 + "source": { 24 + "type": "string", 25 + "description": "Source repository URL" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Branch to check status for" 30 + }, 31 + "hiddenRef": { 32 + "type": "string", 33 + "description": "Hidden ref to use for comparison" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["status"], 43 + "properties": { 44 + "status": { 45 + "type": "integer", 46 + "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+42
lexicons/repo/forkSync.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Sync a forked repository with its upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "did", 14 + "source", 15 + "name", 16 + "branch" 17 + ], 18 + "properties": { 19 + "did": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the fork owner" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "AT-URI of the source repository" 28 + }, 29 + "name": { 30 + "type": "string", 31 + "description": "Name of the forked repository" 32 + }, 33 + "branch": { 34 + "type": "string", 35 + "description": "Branch to sync" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+82
lexicons/repo/getDefaultBranch.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["name", "hash", "when"], 22 + "properties": { 23 + "name": { 24 + "type": "string", 25 + "description": "Default branch name" 26 + }, 27 + "hash": { 28 + "type": "string", 29 + "description": "Latest commit hash on default branch" 30 + }, 31 + "shortHash": { 32 + "type": "string", 33 + "description": "Short commit hash" 34 + }, 35 + "when": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of latest commit" 39 + }, 40 + "message": { 41 + "type": "string", 42 + "description": "Latest commit message" 43 + }, 44 + "author": { 45 + "type": "ref", 46 + "ref": "#signature" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "InvalidRequest", 58 + "description": "Invalid request parameters" 59 + } 60 + ] 61 + }, 62 + "signature": { 63 + "type": "object", 64 + "required": ["name", "email", "when"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "description": "Author name" 69 + }, 70 + "email": { 71 + "type": "string", 72 + "description": "Author email" 73 + }, 74 + "when": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "Author timestamp" 78 + } 79 + } 80 + } 81 + } 82 + }
+59
lexicons/repo/hiddenRef.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+99
lexicons/repo/languages.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.languages", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)", 18 + "default": "HEAD" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["ref", "languages"], 27 + "properties": { 28 + "ref": { 29 + "type": "string", 30 + "description": "The git reference used" 31 + }, 32 + "languages": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#language" 37 + } 38 + }, 39 + "totalSize": { 40 + "type": "integer", 41 + "description": "Total size of all analyzed files in bytes" 42 + }, 43 + "totalFiles": { 44 + "type": "integer", 45 + "description": "Total number of files analyzed" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "RepoNotFound", 53 + "description": "Repository not found or access denied" 54 + }, 55 + { 56 + "name": "RefNotFound", 57 + "description": "Git reference not found" 58 + }, 59 + { 60 + "name": "InvalidRequest", 61 + "description": "Invalid request parameters" 62 + } 63 + ] 64 + }, 65 + "language": { 66 + "type": "object", 67 + "required": ["name", "size", "percentage"], 68 + "properties": { 69 + "name": { 70 + "type": "string", 71 + "description": "Programming language name" 72 + }, 73 + "size": { 74 + "type": "integer", 75 + "description": "Total size of files in this language (bytes)" 76 + }, 77 + "percentage": { 78 + "type": "integer", 79 + "description": "Percentage of total codebase (0-100)" 80 + }, 81 + "fileCount": { 82 + "type": "integer", 83 + "description": "Number of files in this language" 84 + }, 85 + "color": { 86 + "type": "string", 87 + "description": "Hex color code for this language" 88 + }, 89 + "extensions": { 90 + "type": "array", 91 + "items": { 92 + "type": "string" 93 + }, 94 + "description": "File extensions associated with this language" 95 + } 96 + } 97 + } 98 + } 99 + }
+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 + }
+60
lexicons/repo/log.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.log", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to filter commits by", 22 + "default": "" 23 + }, 24 + "limit": { 25 + "type": "integer", 26 + "description": "Maximum number of commits to return", 27 + "minimum": 1, 28 + "maximum": 100, 29 + "default": 50 30 + }, 31 + "cursor": { 32 + "type": "string", 33 + "description": "Pagination cursor (commit SHA)" 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "*/*" 39 + }, 40 + "errors": [ 41 + { 42 + "name": "RepoNotFound", 43 + "description": "Repository not found or access denied" 44 + }, 45 + { 46 + "name": "RefNotFound", 47 + "description": "Git reference not found" 48 + }, 49 + { 50 + "name": "PathNotFound", 51 + "description": "Path not found in repository" 52 + }, 53 + { 54 + "name": "InvalidRequest", 55 + "description": "Invalid request parameters" 56 + } 57 + ] 58 + } 59 + } 60 + }
+52
lexicons/repo/merge.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+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 + }
+53
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 + "minGraphemes": 1, 38 + "maxGraphemes": 140 39 + }, 40 + "source": { 41 + "type": "string", 42 + "format": "uri", 43 + "description": "source of the repo" 44 + }, 45 + "createdAt": { 46 + "type": "string", 47 + "format": "datetime" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+43
lexicons/repo/tags.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of tags to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+123
lexicons/repo/tree.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tree", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path within the repository tree", 22 + "default": "" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["ref", "files"], 31 + "properties": { 32 + "ref": { 33 + "type": "string", 34 + "description": "The git reference used" 35 + }, 36 + "parent": { 37 + "type": "string", 38 + "description": "The parent path in the tree" 39 + }, 40 + "dotdot": { 41 + "type": "string", 42 + "description": "Parent directory path" 43 + }, 44 + "files": { 45 + "type": "array", 46 + "items": { 47 + "type": "ref", 48 + "ref": "#treeEntry" 49 + } 50 + } 51 + } 52 + } 53 + }, 54 + "errors": [ 55 + { 56 + "name": "RepoNotFound", 57 + "description": "Repository not found or access denied" 58 + }, 59 + { 60 + "name": "RefNotFound", 61 + "description": "Git reference not found" 62 + }, 63 + { 64 + "name": "PathNotFound", 65 + "description": "Path not found in repository tree" 66 + }, 67 + { 68 + "name": "InvalidRequest", 69 + "description": "Invalid request parameters" 70 + } 71 + ] 72 + }, 73 + "treeEntry": { 74 + "type": "object", 75 + "required": ["name", "mode", "size", "is_file", "is_subtree"], 76 + "properties": { 77 + "name": { 78 + "type": "string", 79 + "description": "Relative file or directory name" 80 + }, 81 + "mode": { 82 + "type": "string", 83 + "description": "File mode" 84 + }, 85 + "size": { 86 + "type": "integer", 87 + "description": "File size in bytes" 88 + }, 89 + "is_file": { 90 + "type": "boolean", 91 + "description": "Whether this entry is a file" 92 + }, 93 + "is_subtree": { 94 + "type": "boolean", 95 + "description": "Whether this entry is a directory/subtree" 96 + }, 97 + "last_commit": { 98 + "type": "ref", 99 + "ref": "#lastCommit" 100 + } 101 + } 102 + }, 103 + "lastCommit": { 104 + "type": "object", 105 + "required": ["hash", "message", "when"], 106 + "properties": { 107 + "hash": { 108 + "type": "string", 109 + "description": "Commit hash" 110 + }, 111 + "message": { 112 + "type": "string", 113 + "description": "Commit message" 114 + }, 115 + "when": { 116 + "type": "string", 117 + "format": "datetime", 118 + "description": "Commit timestamp" 119 + } 120 + } 121 + } 122 + } 123 + }
-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 // NewHandler sets up a new slog.Handler with the service name 10 // as an attribute 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 13 14 var attrs []slog.Attr 15 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
··· 9 // NewHandler sets up a new slog.Handler with the service name 10 // as an attribute 11 func NewHandler(name string) slog.Handler { 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 15 16 var attrs []slog.Attr 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+99 -21
nix/gomod2nix.toml
··· 11 version = "v0.6.2" 12 hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 [mod."github.com/ProtonMail/go-crypto"] 14 - version = "v1.2.0" 15 - hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo=" 16 [mod."github.com/alecthomas/chroma/v2"] 17 version = "v2.19.0" 18 hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 19 replaced = "github.com/oppiliappan/chroma/v2" 20 [mod."github.com/anmitsu/go-shlex"] 21 version = "v0.0.0-20200514113438-38f4b401e2be" 22 hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" ··· 51 [mod."github.com/casbin/govaluate"] 52 version = "v1.3.0" 53 hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 54 [mod."github.com/cespare/xxhash/v2"] 55 version = "v2.3.0" 56 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 57 [mod."github.com/cloudflare/circl"] 58 - version = "v1.6.0" 59 - hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0=" 60 [mod."github.com/containerd/errdefs"] 61 version = "v1.0.0" 62 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 105 [mod."github.com/felixge/httpsnoop"] 106 version = "v1.0.4" 107 hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 108 [mod."github.com/gliderlabs/ssh"] 109 version = "v0.3.8" 110 hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" ··· 127 version = "v5.17.0" 128 hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 129 replaced = "github.com/oppiliappan/go-git/v5" 130 [mod."github.com/go-logr/logr"] 131 version = "v1.4.3" 132 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 136 [mod."github.com/go-redis/cache/v9"] 137 version = "v9.0.0" 138 hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 139 [mod."github.com/goccy/go-json"] 140 version = "v0.10.5" 141 hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" ··· 148 [mod."github.com/golang/groupcache"] 149 version = "v0.0.0-20241129210726-2c02b8208cf8" 150 hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 151 [mod."github.com/google/uuid"] 152 version = "v1.6.0" 153 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 154 [mod."github.com/gorilla/css"] 155 version = "v1.0.1" 156 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 157 [mod."github.com/gorilla/securecookie"] 158 version = "v1.1.2" 159 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" ··· 161 version = "v1.4.0" 162 hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 163 [mod."github.com/gorilla/websocket"] 164 - version = "v1.5.3" 165 - hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0=" 166 [mod."github.com/hashicorp/go-cleanhttp"] 167 version = "v0.5.2" 168 hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 169 [mod."github.com/hashicorp/go-retryablehttp"] 170 version = "v0.7.8" 171 hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 172 [mod."github.com/hashicorp/golang-lru"] 173 version = "v1.0.2" 174 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 175 [mod."github.com/hashicorp/golang-lru/v2"] 176 version = "v2.0.7" 177 hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 178 [mod."github.com/hiddeco/sshsig"] 179 version = "v0.2.0" 180 hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" ··· 256 [mod."github.com/minio/sha256-simd"] 257 version = "v1.0.1" 258 hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 259 [mod."github.com/moby/docker-image-spec"] 260 version = "v1.3.1" 261 hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" ··· 289 [mod."github.com/munnerz/goautoneg"] 290 version = "v0.0.0-20191010083416-a7dc8b61c822" 291 hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 292 [mod."github.com/opencontainers/go-digest"] 293 version = "v1.0.0" 294 hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" ··· 296 version = "v1.1.1" 297 hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 298 [mod."github.com/opentracing/opentracing-go"] 299 - version = "v1.2.0" 300 - hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM=" 301 [mod."github.com/pjbgf/sha1cd"] 302 version = "v0.3.2" 303 hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" ··· 326 version = "v0.16.1" 327 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 328 [mod."github.com/redis/go-redis/v9"] 329 - version = "v9.3.0" 330 - hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w=" 331 [mod."github.com/resend/resend-go/v2"] 332 version = "v2.15.0" 333 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 334 [mod."github.com/segmentio/asm"] 335 version = "v1.2.0" 336 hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" ··· 362 [mod."github.com/whyrusleeping/cbor-gen"] 363 version = "v0.3.1" 364 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 365 [mod."github.com/yuin/goldmark"] 366 - version = "v1.4.13" 367 - hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI=" 368 [mod."gitlab.com/yawning/secp256k1-voi"] 369 version = "v0.0.0-20230925100816-f2616030848b" 370 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 380 [mod."go.opentelemetry.io/otel"] 381 version = "v1.37.0" 382 hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 383 [mod."go.opentelemetry.io/otel/metric"] 384 version = "v1.37.0" 385 hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" ··· 405 version = "v0.0.0-20250620022241-b7579e27df2b" 406 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 407 [mod."golang.org/x/net"] 408 - version = "v0.41.0" 409 - hash = "sha256-6/pi8rNmGvBFzkJQXkXkMfL1Bjydhg3BgAMYDyQ/Uvg=" 410 [mod."golang.org/x/sync"] 411 - version = "v0.15.0" 412 - hash = "sha256-Jf4ehm8H8YAWY6mM151RI5CbG7JcOFtmN0AZx4bE3UE=" 413 [mod."golang.org/x/sys"] 414 version = "v0.34.0" 415 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 416 [mod."golang.org/x/time"] 417 version = "v0.12.0" 418 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 420 version = "v0.0.0-20240903120638-7835f813f4da" 421 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 422 [mod."google.golang.org/genproto/googleapis/api"] 423 - version = "v0.0.0-20250519155744-55703ea1f237" 424 - hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ=" 425 [mod."google.golang.org/genproto/googleapis/rpc"] 426 - version = "v0.0.0-20250519155744-55703ea1f237" 427 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 428 [mod."google.golang.org/grpc"] 429 - version = "v1.72.1" 430 - hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs=" 431 [mod."google.golang.org/protobuf"] 432 version = "v1.36.6" 433 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 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=" ··· 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 version = "v0.3.1" 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/wyatt915/goldmark-treeblood"] 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 + [mod."github.com/wyatt915/treeblood"] 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 [mod."github.com/yuin/goldmark"] 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 + [mod."github.com/yuin/goldmark-highlighting/v2"] 438 + version = "v2.0.0-20230729083705-37449abec8cc" 439 + hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 440 [mod."gitlab.com/yawning/secp256k1-voi"] 441 version = "v0.0.0-20230925100816-f2616030848b" 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 452 [mod."go.opentelemetry.io/otel"] 453 version = "v1.37.0" 454 hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 455 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 456 + version = "v1.33.0" 457 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 458 [mod."go.opentelemetry.io/otel/metric"] 459 version = "v1.37.0" 460 hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" ··· 480 version = "v0.0.0-20250620022241-b7579e27df2b" 481 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 482 [mod."golang.org/x/net"] 483 + version = "v0.42.0" 484 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 [mod."golang.org/x/sync"] 486 + version = "v0.16.0" 487 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 488 [mod."golang.org/x/sys"] 489 version = "v0.34.0" 490 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 + [mod."golang.org/x/text"] 492 + version = "v0.27.0" 493 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 494 [mod."golang.org/x/time"] 495 version = "v0.12.0" 496 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 498 version = "v0.0.0-20240903120638-7835f813f4da" 499 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 500 [mod."google.golang.org/genproto/googleapis/api"] 501 + version = "v0.0.0-20250603155806-513f23925822" 502 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 503 [mod."google.golang.org/genproto/googleapis/rpc"] 504 + version = "v0.0.0-20250603155806-513f23925822" 505 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 506 [mod."google.golang.org/grpc"] 507 + version = "v1.73.0" 508 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 509 [mod."google.golang.org/protobuf"] 510 version = "v1.36.6" 511 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
+14
nix/modules/appview.nix
··· 27 default = "00000000000000000000000000000000"; 28 description = "Cookie secret"; 29 }; 30 }; 31 }; 32 ··· 39 ListenStream = "0.0.0.0:${toString cfg.port}"; 40 ExecStart = "${cfg.package}/bin/appview"; 41 Restart = "always"; 42 }; 43 44 environment = {
··· 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 + }; 43 }; 44 }; 45 ··· 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 }; 57 58 environment = {
+54 -20
nix/modules/knot.nix
··· 58 }; 59 }; 60 61 server = { 62 listenAddr = mkOption { 63 type = types.str; ··· 71 description = "Internal address for inter-service communication"; 72 }; 73 74 - secretFile = mkOption { 75 - type = lib.types.path; 76 - example = "KNOT_SERVER_SECRET=<hash>"; 77 - description = "File containing secret key provided by appview (required)"; 78 }; 79 80 dbPath = mkOption { ··· 104 cfg.package 105 ]; 106 107 - system.activationScripts.gitConfig = '' 108 - mkdir -p "${cfg.repo.scanPath}" 109 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 110 - 111 - mkdir -p "${cfg.stateDir}/.config/git" 112 - cat > "${cfg.stateDir}/.config/git/config" << EOF 113 - [user] 114 - name = Git User 115 - email = git@example.com 116 - [receive] 117 - advertisePushOptions = true 118 - EOF 119 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 120 - ''; 121 - 122 users.users.${cfg.gitUser} = { 123 isSystemUser = true; 124 useDefaultShell = true; ··· 154 description = "knot service"; 155 after = ["network.target" "sshd.service"]; 156 wantedBy = ["multi-user.target"]; 157 serviceConfig = { 158 User = cfg.gitUser; 159 WorkingDirectory = cfg.stateDir; 160 Environment = [ 161 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" ··· 165 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 166 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 167 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 168 ]; 169 - EnvironmentFile = cfg.server.secretFile; 170 ExecStart = "${cfg.package}/bin/knot server"; 171 Restart = "always"; 172 };
··· 58 }; 59 }; 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 83 server = { 84 listenAddr = mkOption { 85 type = types.str; ··· 93 description = "Internal address for inter-service communication"; 94 }; 95 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (required)"; 100 }; 101 102 dbPath = mkOption { ··· 126 cfg.package 127 ]; 128 129 users.users.${cfg.gitUser} = { 130 isSystemUser = true; 131 useDefaultShell = true; ··· 161 description = "knot service"; 162 after = ["network.target" "sshd.service"]; 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 + 190 serviceConfig = { 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 193 WorkingDirectory = cfg.stateDir; 194 Environment = [ 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" ··· 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 203 ]; 204 ExecStart = "${cfg.package}/bin/knot server"; 205 Restart = "always"; 206 };
+40 -2
nix/modules/spindle.nix
··· 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 description = "DID of owner (required)"; 56 }; 57 }; 58 59 pipelines = { ··· 89 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 90 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 92 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 ]; 95 ExecStart = "${cfg.package}/bin/spindle"; 96 Restart = "always";
··· 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 description = "DID of owner (required)"; 56 }; 57 + 58 + maxJobCount = mkOption { 59 + type = types.int; 60 + default = 2; 61 + example = 5; 62 + description = "Maximum number of concurrent jobs to run"; 63 + }; 64 + 65 + queueSize = mkOption { 66 + type = types.int; 67 + default = 100; 68 + example = 100; 69 + description = "Maximum number of jobs queue up"; 70 + }; 71 + 72 + secrets = { 73 + provider = mkOption { 74 + type = types.str; 75 + default = "sqlite"; 76 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 77 + }; 78 + 79 + openbao = { 80 + proxyAddr = mkOption { 81 + type = types.str; 82 + default = "http://127.0.0.1:8200"; 83 + }; 84 + mount = mkOption { 85 + type = types.str; 86 + default = "spindle"; 87 + }; 88 + }; 89 + }; 90 }; 91 92 pipelines = { ··· 122 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 + "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" 126 + "SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}" 127 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 128 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 129 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 130 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 131 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 132 ]; 133 ExecStart = "${cfg.package}/bin/spindle"; 134 Restart = "always";
+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 + ''
+5 -17
nix/pkgs/appview.nix
··· 1 { 2 buildGoApplication, 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 10 sqlite-lib, 11 - gitignoreSource, 12 }: 13 buildGoApplication { 14 pname = "appview"; 15 version = "0.1.0"; 16 - src = gitignoreSource ../..; 17 - inherit modules; 18 19 postUnpack = '' 20 pushd source 21 - mkdir -p appview/pages/static/{fonts,icons} 22 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 23 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 24 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 25 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 26 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 27 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 28 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 29 popd 30 ''; 31
··· 1 { 2 buildGoApplication, 3 modules, 4 + appview-static-files, 5 sqlite-lib, 6 + src, 7 }: 8 buildGoApplication { 9 pname = "appview"; 10 version = "0.1.0"; 11 + inherit src modules; 12 13 postUnpack = '' 14 pushd source 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 17 popd 18 ''; 19
+7 -3
nix/pkgs/genjwks.nix
··· 1 { 2 - gitignoreSource, 3 buildGoApplication, 4 modules, 5 }: 6 buildGoApplication { 7 pname = "genjwks"; 8 version = "0.1.0"; 9 - src = gitignoreSource ../..; 10 inherit modules; 11 - subPackages = ["cmd/genjwks"]; 12 doCheck = false; 13 CGO_ENABLED = 0; 14 }
··· 1 { 2 buildGoApplication, 3 modules, 4 }: 5 buildGoApplication { 6 pname = "genjwks"; 7 version = "0.1.0"; 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; 16 doCheck = false; 17 CGO_ENABLED = 0; 18 }
+18 -14
nix/pkgs/knot-unwrapped.nix
··· 2 buildGoApplication, 3 modules, 4 sqlite-lib, 5 - gitignoreSource, 6 - }: 7 - buildGoApplication { 8 - pname = "knot"; 9 - version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 12 13 - doCheck = false; 14 15 - subPackages = ["cmd/knot"]; 16 - tags = ["libsqlite3"]; 17 18 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 19 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 20 - CGO_ENABLED = 1; 21 - }
··· 2 buildGoApplication, 3 modules, 4 sqlite-lib, 5 + src, 6 + }: let 7 + version = "1.9.0-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 13 + doCheck = false; 14 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 17 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 21 + 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }
+1 -1
nix/pkgs/lexgen.nix
··· 7 version = "0.1.0"; 8 src = indigo; 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 11 doCheck = false; 12 }
··· 7 version = "0.1.0"; 8 src = indigo; 9 subPackages = ["cmd/lexgen"]; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 doCheck = false; 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 buildGoApplication, 3 modules, 4 sqlite-lib, 5 - gitignoreSource, 6 }: 7 buildGoApplication { 8 pname = "spindle"; 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 12 13 doCheck = false; 14
··· 2 buildGoApplication, 3 modules, 4 sqlite-lib, 5 + src, 6 }: 7 buildGoApplication { 8 pname = "spindle"; 9 version = "0.1.0"; 10 + inherit src modules; 11 12 doCheck = false; 13
+123 -63
nix/vm.nix
··· 1 { 2 nixpkgs, 3 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"; 55 }; 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; 64 }; 65 - }; 66 - }) 67 - ]; 68 - }
··· 1 { 2 nixpkgs, 3 + system, 4 + hostSystem, 5 self, 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 + time.timeZone = "Europe/London"; 74 + services.getty.autologinUser = "root"; 75 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 + services.tangled-knot = { 77 + enable = true; 78 + motd = "Welcome to the development knot!\n"; 79 + server = { 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 + hostname = "localhost:6000"; 82 + listenAddr = "0.0.0.0:6000"; 83 + }; 84 + }; 85 + services.tangled-spindle = { 86 + enable = true; 87 + server = { 88 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 + hostname = "localhost:6555"; 90 + listenAddr = "0.0.0.0:6555"; 91 + dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 94 + secrets = { 95 + provider = "sqlite"; 96 + }; 97 + }; 98 }; 99 + users = { 100 + # So we don't have to deal with permission clashing between 101 + # blank disk VMs and existing state 102 + users.${config.services.tangled-knot.gitUser}.uid = 666; 103 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 104 + 105 + # TODO: separate spindle user 106 + }; 107 + systemd.services = let 108 + mkDataSyncScripts = source: target: { 109 + enableStrictShellChecks = true; 110 + 111 + preStart = lib.mkBefore '' 112 + mkdir -p ${target} 113 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 114 + ''; 115 + 116 + postStop = lib.mkAfter '' 117 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 118 + ''; 119 + 120 + serviceConfig.PermissionsStartOnly = true; 121 + }; 122 + in { 123 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 125 + }; 126 + }) 127 + ]; 128 + }
+1 -1
patchutil/combinediff.go
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 - fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 + // fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
+14 -1
rbac/rbac.go
··· 43 return nil, err 44 } 45 46 - db, err := sql.Open("sqlite3", path) 47 if err != nil { 48 return nil, err 49 } ··· 97 func (e *Enforcer) RemoveSpindle(spindle string) error { 98 spindle = intoSpindle(spindle) 99 _, err := e.E.DeleteDomains(spindle) 100 return err 101 } 102 ··· 270 271 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 272 return e.isInviteAllowed(user, intoSpindle(domain)) 273 } 274 275 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
··· 43 return nil, err 44 } 45 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 47 if err != nil { 48 return nil, err 49 } ··· 97 func (e *Enforcer) RemoveSpindle(spindle string) error { 98 spindle = intoSpindle(spindle) 99 _, err := e.E.DeleteDomains(spindle) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) RemoveKnot(knot string) error { 104 + _, err := e.E.DeleteDomains(knot) 105 return err 106 } 107 ··· 275 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 277 return e.isInviteAllowed(user, intoSpindle(domain)) 278 + } 279 + 280 + func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 + return e.E.Enforce(user, domain, domain, "repo:create") 282 + } 283 + 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 286 } 287 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+1 -1
rbac/rbac_test.go
··· 14 ) 15 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 18 assert.NoError(t, err) 19 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
··· 14 ) 15 16 func setup(t *testing.T) *rbac.Enforcer { 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 assert.NoError(t, err) 19 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+8 -8
spindle/config/config.go
··· 16 Dev bool `env:"DEV, default=false"` 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 } 20 21 func (s Server) Did() syntax.DID { ··· 28 } 29 30 type OpenBaoConfig struct { 31 - Addr string `env:"ADDR"` 32 - RoleID string `env:"ROLE_ID"` 33 - SecretID string `env:"SECRET_ID"` 34 - Mount string `env:"MOUNT, default=spindle"` 35 } 36 37 - type Pipelines struct { 38 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 39 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 40 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 41 } 42 43 type Config struct { 44 - Server Server `env:",prefix=SPINDLE_SERVER_"` 45 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 46 } 47 48 func Load(ctx context.Context) (*Config, error) {
··· 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 + QueueSize int `env:"QUEUE_SIZE, default=100"` 21 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 22 } 23 24 func (s Server) Did() syntax.DID { ··· 31 } 32 33 type OpenBaoConfig struct { 34 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 35 + Mount string `env:"MOUNT, default=spindle"` 36 } 37 38 + type NixeryPipelines struct { 39 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 40 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 41 } 42 43 type Config struct { 44 + Server Server `env:",prefix=SPINDLE_SERVER_"` 45 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 46 } 47 48 func Load(ctx context.Context) (*Config, error) {
+29 -10
spindle/db/db.go
··· 2 3 import ( 4 "database/sql" 5 6 _ "github.com/mattn/go-sqlite3" 7 ) ··· 11 } 12 13 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 if err != nil { 16 return nil, err 17 } 18 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 - 29 create table if not exists _jetstream ( 30 id integer primary key autoincrement, 31 last_time_us integer not null ··· 43 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 45 unique(owner, name) 46 ); 47 48 -- status event for a single workflow
··· 2 3 import ( 4 "database/sql" 5 + "strings" 6 7 _ "github.com/mattn/go-sqlite3" 8 ) ··· 12 } 13 14 func Make(dbPath string) (*DB, error) { 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, "&")) 24 if err != nil { 25 return nil, err 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. 31 32 _, err = db.Exec(` 33 create table if not exists _jetstream ( 34 id integer primary key autoincrement, 35 last_time_us integer not null ··· 47 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 48 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) 65 ); 66 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 - }
···
+68 -415
spindle/engine/engine.go
··· 4 "context" 5 "errors" 6 "fmt" 7 - "io" 8 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 14 securejoin "github.com/cyphar/filepath-securejoin" 15 - "github.com/docker/docker/api/types/container" 16 - "github.com/docker/docker/api/types/image" 17 - "github.com/docker/docker/api/types/mount" 18 - "github.com/docker/docker/api/types/network" 19 - "github.com/docker/docker/api/types/volume" 20 - "github.com/docker/docker/client" 21 - "github.com/docker/docker/pkg/stdcopy" 22 "golang.org/x/sync/errgroup" 23 - "tangled.sh/tangled.sh/core/log" 24 "tangled.sh/tangled.sh/core/notifier" 25 "tangled.sh/tangled.sh/core/spindle/config" 26 "tangled.sh/tangled.sh/core/spindle/db" ··· 28 "tangled.sh/tangled.sh/core/spindle/secrets" 29 ) 30 31 - const ( 32 - workspaceDir = "/tangled/workspace" 33 ) 34 35 - type cleanupFunc func(context.Context) error 36 - 37 - type Engine struct { 38 - docker client.APIClient 39 - l *slog.Logger 40 - db *db.DB 41 - n *notifier.Notifier 42 - cfg *config.Config 43 - vault secrets.Manager 44 - 45 - cleanupMu sync.Mutex 46 - cleanup map[string][]cleanupFunc 47 - } 48 - 49 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 50 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 - if err != nil { 52 - return nil, err 53 - } 54 - 55 - l := log.FromContext(ctx).With("component", "spindle") 56 - 57 - e := &Engine{ 58 - docker: dcli, 59 - l: l, 60 - db: db, 61 - n: n, 62 - cfg: cfg, 63 - vault: vault, 64 - } 65 - 66 - e.cleanup = make(map[string][]cleanupFunc) 67 - 68 - return e, nil 69 - } 70 - 71 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 72 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 73 74 // extract secrets 75 var allSecrets []secrets.UnlockedSecret 76 if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 - if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 allSecrets = res 79 } 80 } 81 82 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 83 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 84 - if err != nil { 85 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 86 - workflowTimeout = 5 * time.Minute 87 - } 88 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 89 - 90 eg, ctx := errgroup.WithContext(ctx) 91 - for _, w := range pipeline.Workflows { 92 - eg.Go(func() error { 93 - wid := models.WorkflowId{ 94 - PipelineId: pipelineId, 95 - Name: w.Name, 96 - } 97 - 98 - err := e.db.StatusRunning(wid, e.n) 99 - if err != nil { 100 - return err 101 - } 102 103 - err = e.SetupWorkflow(ctx, wid) 104 - if err != nil { 105 - e.l.Error("setting up worklow", "wid", wid, "err", err) 106 - return err 107 - } 108 - defer e.DestroyWorkflow(ctx, wid) 109 - 110 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 111 - if err != nil { 112 - e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error()) 113 114 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 115 if err != nil { 116 return err 117 } 118 119 - return fmt.Errorf("pulling image: %w", err) 120 - } 121 - defer reader.Close() 122 - io.Copy(os.Stdout, reader) 123 - 124 - ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 - defer cancel() 126 127 - err = e.StartSteps(ctx, wid, w, allSecrets) 128 - if err != nil { 129 - if errors.Is(err, ErrTimedOut) { 130 - dbErr := e.db.StatusTimeout(wid, e.n) 131 - if dbErr != nil { 132 - return dbErr 133 } 134 - } else { 135 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 136 if dbErr != nil { 137 return dbErr 138 } 139 } 140 141 - return fmt.Errorf("starting steps image: %w", err) 142 - } 143 144 - err = e.db.StatusSuccess(wid, e.n) 145 - if err != nil { 146 - return err 147 - } 148 149 - return nil 150 - }) 151 - } 152 153 - if err = eg.Wait(); err != nil { 154 - e.l.Error("failed to run one or more workflows", "err", err) 155 - } else { 156 - e.l.Error("successfully ran full pipeline") 157 - } 158 - } 159 160 - // SetupWorkflow sets up a new network for the workflow and volumes for 161 - // the workspace and Nix store. These are persisted across steps and are 162 - // destroyed at the end of the workflow. 163 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 164 - e.l.Info("setting up workflow", "workflow", wid) 165 166 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 167 - Name: workspaceVolume(wid), 168 - Driver: "local", 169 - }) 170 - if err != nil { 171 - return err 172 - } 173 - e.registerCleanup(wid, func(ctx context.Context) error { 174 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 175 - }) 176 - 177 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 178 - Name: nixVolume(wid), 179 - Driver: "local", 180 - }) 181 - if err != nil { 182 - return err 183 - } 184 - e.registerCleanup(wid, func(ctx context.Context) error { 185 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 186 - }) 187 - 188 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 189 - Driver: "bridge", 190 - }) 191 - if err != nil { 192 - return err 193 - } 194 - e.registerCleanup(wid, func(ctx context.Context) error { 195 - return e.docker.NetworkRemove(ctx, networkName(wid)) 196 - }) 197 198 - return nil 199 - } 200 - 201 - // StartSteps starts all steps sequentially with the same base image. 202 - // ONLY marks pipeline as failed if container's exit code is non-zero. 203 - // All other errors are bubbled up. 204 - // Fixed version of the step execution logic 205 - func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 206 - workflowEnvs := ConstructEnvs(w.Environment) 207 - for _, s := range secrets { 208 - workflowEnvs.AddEnv(s.Key, s.Value) 209 - } 210 - 211 - for stepIdx, step := range w.Steps { 212 - select { 213 - case <-ctx.Done(): 214 - return ctx.Err() 215 - default: 216 - } 217 - 218 - envs := append(EnvVars(nil), workflowEnvs...) 219 - for k, v := range step.Environment { 220 - envs.AddEnv(k, v) 221 - } 222 - envs.AddEnv("HOME", workspaceDir) 223 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 224 - 225 - hostConfig := hostConfig(wid) 226 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 227 - Image: w.Image, 228 - Cmd: []string{"bash", "-c", step.Command}, 229 - WorkingDir: workspaceDir, 230 - Tty: false, 231 - Hostname: "spindle", 232 - Env: envs.Slice(), 233 - }, hostConfig, nil, nil, "") 234 - defer e.DestroyStep(ctx, resp.ID) 235 - if err != nil { 236 - return fmt.Errorf("creating container: %w", err) 237 - } 238 - 239 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 240 - if err != nil { 241 - return fmt.Errorf("connecting network: %w", err) 242 - } 243 - 244 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 - if err != nil { 246 - return err 247 - } 248 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 249 - 250 - // start tailing logs in background 251 - tailDone := make(chan error, 1) 252 - go func() { 253 - tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step) 254 - }() 255 - 256 - // wait for container completion or timeout 257 - waitDone := make(chan struct{}) 258 - var state *container.State 259 - var waitErr error 260 - 261 - go func() { 262 - defer close(waitDone) 263 - state, waitErr = e.WaitStep(ctx, resp.ID) 264 - }() 265 - 266 - select { 267 - case <-waitDone: 268 - 269 - // wait for tailing to complete 270 - <-tailDone 271 - 272 - case <-ctx.Done(): 273 - e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name) 274 - err = e.DestroyStep(context.Background(), resp.ID) 275 - if err != nil { 276 - e.l.Error("failed to destroy step", "container", resp.ID, "error", err) 277 - } 278 - 279 - // wait for both goroutines to finish 280 - <-waitDone 281 - <-tailDone 282 - 283 - return ErrTimedOut 284 - } 285 - 286 - select { 287 - case <-ctx.Done(): 288 - return ctx.Err() 289 - default: 290 - } 291 - 292 - if waitErr != nil { 293 - return waitErr 294 - } 295 - 296 - err = e.DestroyStep(ctx, resp.ID) 297 - if err != nil { 298 - return err 299 - } 300 - 301 - if state.ExitCode != 0 { 302 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 303 - if state.OOMKilled { 304 - return ErrOOMKilled 305 - } 306 - return ErrWorkflowFailed 307 } 308 } 309 310 - return nil 311 - } 312 - 313 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 314 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 315 - select { 316 - case err := <-errCh: 317 - if err != nil { 318 - return nil, err 319 - } 320 - case <-wait: 321 - } 322 - 323 - e.l.Info("waited for container", "name", containerID) 324 - 325 - info, err := e.docker.ContainerInspect(ctx, containerID) 326 - if err != nil { 327 - return nil, err 328 - } 329 - 330 - return info.State, nil 331 - } 332 - 333 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 334 - wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid) 335 - if err != nil { 336 - e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 337 - return err 338 } 339 - defer wfLogger.Close() 340 - 341 - ctl := wfLogger.ControlWriter(stepIdx, step) 342 - ctl.Write([]byte(step.Name)) 343 - 344 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 345 - Follow: true, 346 - ShowStdout: true, 347 - ShowStderr: true, 348 - Details: false, 349 - Timestamps: false, 350 - }) 351 - if err != nil { 352 - return err 353 - } 354 - 355 - _, err = stdcopy.StdCopy( 356 - wfLogger.DataWriter("stdout"), 357 - wfLogger.DataWriter("stderr"), 358 - logs, 359 - ) 360 - if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 361 - return fmt.Errorf("failed to copy logs: %w", err) 362 - } 363 - 364 - return nil 365 - } 366 - 367 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 368 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 369 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 370 - return err 371 - } 372 - 373 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 374 - RemoveVolumes: true, 375 - RemoveLinks: false, 376 - Force: false, 377 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 378 - return err 379 - } 380 - 381 - return nil 382 - } 383 - 384 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 385 - e.cleanupMu.Lock() 386 - key := wid.String() 387 - 388 - fns := e.cleanup[key] 389 - delete(e.cleanup, key) 390 - e.cleanupMu.Unlock() 391 - 392 - for _, fn := range fns { 393 - if err := fn(ctx); err != nil { 394 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 395 - } 396 - } 397 - return nil 398 - } 399 - 400 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 401 - e.cleanupMu.Lock() 402 - defer e.cleanupMu.Unlock() 403 - 404 - key := wid.String() 405 - e.cleanup[key] = append(e.cleanup[key], fn) 406 - } 407 - 408 - func workspaceVolume(wid models.WorkflowId) string { 409 - return fmt.Sprintf("workspace-%s", wid) 410 - } 411 - 412 - func nixVolume(wid models.WorkflowId) string { 413 - return fmt.Sprintf("nix-%s", wid) 414 - } 415 - 416 - func networkName(wid models.WorkflowId) string { 417 - return fmt.Sprintf("workflow-network-%s", wid) 418 - } 419 - 420 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 421 - hostConfig := &container.HostConfig{ 422 - Mounts: []mount.Mount{ 423 - { 424 - Type: mount.TypeVolume, 425 - Source: workspaceVolume(wid), 426 - Target: workspaceDir, 427 - }, 428 - { 429 - Type: mount.TypeVolume, 430 - Source: nixVolume(wid), 431 - Target: "/nix", 432 - }, 433 - { 434 - Type: mount.TypeTmpfs, 435 - Target: "/tmp", 436 - ReadOnly: false, 437 - TmpfsOptions: &mount.TmpfsOptions{ 438 - Mode: 0o1777, // world-writeable sticky bit 439 - Options: [][]string{ 440 - {"exec"}, 441 - }, 442 - }, 443 - }, 444 - { 445 - Type: mount.TypeVolume, 446 - Source: "etc-nix-" + wid.String(), 447 - Target: "/etc/nix", 448 - }, 449 - }, 450 - ReadonlyRootfs: false, 451 - CapDrop: []string{"ALL"}, 452 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 453 - SecurityOpt: []string{"no-new-privileges"}, 454 - ExtraHosts: []string{"host.docker.internal:host-gateway"}, 455 - } 456 - 457 - return hostConfig 458 - } 459 - 460 - // thanks woodpecker 461 - func isErrContainerNotFoundOrNotRunning(err error) bool { 462 - // Error response from daemon: Cannot kill container: ...: No such container: ... 463 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 464 - // Error response from podman daemon: can only kill running containers. ... is in state exited 465 - // Error: No such container: ... 466 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 467 }
··· 4 "context" 5 "errors" 6 "fmt" 7 "log/slog" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "golang.org/x/sync/errgroup" 11 "tangled.sh/tangled.sh/core/notifier" 12 "tangled.sh/tangled.sh/core/spindle/config" 13 "tangled.sh/tangled.sh/core/spindle/db" ··· 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 ) 17 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 21 ) 22 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) 25 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 } 32 } 33 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) 38 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 45 46 + err := db.StatusRunning(wid, n) 47 if err != nil { 48 return err 49 } 50 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) 56 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 60 } 61 + 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 if dbErr != nil { 64 return dbErr 65 } 66 + return err 67 } 68 + defer eng.DestroyWorkflow(ctx, wid) 69 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 + } 77 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 80 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 86 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 + } 100 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 104 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 109 110 + return nil 111 + }) 112 } 113 } 114 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") 119 } 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
···
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.ElementsMatch(t, tt.want, got) 38 - }) 39 - } 40 - } 41 - 42 - func TestAddEnv(t *testing.T) { 43 - ev := EnvVars{} 44 - ev.AddEnv("FOO", "bar") 45 - ev.AddEnv("BAZ", "qux") 46 - want := EnvVars{"FOO=bar", "BAZ=qux"} 47 - assert.ElementsMatch(t, want, ev) 48 - }
···
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
···
-84
spindle/engine/logger.go
··· 1 - package engine 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "path/filepath" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 - ) 13 - 14 - type WorkflowLogger struct { 15 - file *os.File 16 - encoder *json.Encoder 17 - } 18 - 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - path := LogFilePath(baseDir, wid) 21 - 22 - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 23 - if err != nil { 24 - return nil, fmt.Errorf("creating log file: %w", err) 25 - } 26 - 27 - return &WorkflowLogger{ 28 - file: file, 29 - encoder: json.NewEncoder(file), 30 - }, nil 31 - } 32 - 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 - logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 - return logFilePath 36 - } 37 - 38 - func (l *WorkflowLogger) Close() error { 39 - return l.file.Close() 40 - } 41 - 42 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 - // TODO: emit stream 44 - return &dataWriter{ 45 - logger: l, 46 - stream: stream, 47 - } 48 - } 49 - 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 - return &controlWriter{ 52 - logger: l, 53 - idx: idx, 54 - step: step, 55 - } 56 - } 57 - 58 - type dataWriter struct { 59 - logger *WorkflowLogger 60 - stream string 61 - } 62 - 63 - func (w *dataWriter) Write(p []byte) (int, error) { 64 - line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 - if err := w.logger.encoder.Encode(entry); err != nil { 67 - return 0, err 68 - } 69 - return len(p), nil 70 - } 71 - 72 - type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step models.Step 76 - } 77 - 78 - func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 - if err := w.logger.encoder.Encode(entry); err != nil { 81 - return 0, err 82 - } 83 - return len(w.step.Name), nil 84 - }
···
+21
spindle/engines/nixery/ansi_stripper.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+421
spindle/engines/nixery/engine.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + Labels: map[string]string{ 205 + "sh.tangled.pipeline/workflow_id": wid.String(), 206 + }, 207 + // TODO(winter): investigate whether environment variables passed here 208 + // get propagated to ContainerExec processes 209 + }, &container.HostConfig{ 210 + Mounts: []mount.Mount{ 211 + { 212 + Type: mount.TypeTmpfs, 213 + Target: "/tmp", 214 + ReadOnly: false, 215 + TmpfsOptions: &mount.TmpfsOptions{ 216 + Mode: 0o1777, // world-writeable sticky bit 217 + Options: [][]string{ 218 + {"exec"}, 219 + }, 220 + }, 221 + }, 222 + }, 223 + ReadonlyRootfs: false, 224 + CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 + SecurityOpt: []string{"no-new-privileges"}, 227 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 + }, nil, nil, "") 229 + if err != nil { 230 + return fmt.Errorf("creating container: %w", err) 231 + } 232 + e.registerCleanup(wid, func(ctx context.Context) error { 233 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 + RemoveVolumes: true, 240 + RemoveLinks: false, 241 + Force: false, 242 + }) 243 + }) 244 + 245 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 246 + if err != nil { 247 + return fmt.Errorf("starting container: %w", err) 248 + } 249 + 250 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 251 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 252 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 253 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 254 + }) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + // This actually *starts* the command. Thanks, Docker! 260 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 261 + if err != nil { 262 + return err 263 + } 264 + defer execResp.Close() 265 + 266 + // This is apparently best way to wait for the command to complete. 267 + _, err = io.ReadAll(execResp.Reader) 268 + if err != nil { 269 + return err 270 + } 271 + 272 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + if execInspectResp.ExitCode != 0 { 278 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 279 + } else if execInspectResp.Running { 280 + return errors.New("mkdir is somehow still running??") 281 + } 282 + 283 + addl.container = resp.ID 284 + wf.Data = addl 285 + 286 + return nil 287 + } 288 + 289 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 + addl := w.Data.(addlFields) 291 + workflowEnvs := ConstructEnvs(addl.env) 292 + // TODO(winter): should SetupWorkflow also have secret access? 293 + // IMO yes, but probably worth thinking on. 294 + for _, s := range secrets { 295 + workflowEnvs.AddEnv(s.Key, s.Value) 296 + } 297 + 298 + step := w.Steps[idx].(Step) 299 + 300 + select { 301 + case <-ctx.Done(): 302 + return ctx.Err() 303 + default: 304 + } 305 + 306 + envs := append(EnvVars(nil), workflowEnvs...) 307 + for k, v := range step.environment { 308 + envs.AddEnv(k, v) 309 + } 310 + envs.AddEnv("HOME", homeDir) 311 + 312 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 + Cmd: []string{"bash", "-c", step.command}, 314 + AttachStdout: true, 315 + AttachStderr: true, 316 + Env: envs, 317 + }) 318 + if err != nil { 319 + return fmt.Errorf("creating exec: %w", err) 320 + } 321 + 322 + // start tailing logs in background 323 + tailDone := make(chan error, 1) 324 + go func() { 325 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 326 + }() 327 + 328 + select { 329 + case <-tailDone: 330 + 331 + case <-ctx.Done(): 332 + // cleanup will be handled by DestroyWorkflow, since 333 + // Docker doesn't provide an API to kill an exec run 334 + // (sure, we could grab the PID and kill it ourselves, 335 + // but that's wasted effort) 336 + e.l.Warn("step timed out", "step", step.Name) 337 + 338 + <-tailDone 339 + 340 + return engine.ErrTimedOut 341 + } 342 + 343 + select { 344 + case <-ctx.Done(): 345 + return ctx.Err() 346 + default: 347 + } 348 + 349 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + if execInspectResp.ExitCode != 0 { 355 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 356 + if err != nil { 357 + return err 358 + } 359 + 360 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 361 + 362 + if inspectResp.State.OOMKilled { 363 + return ErrOOMKilled 364 + } 365 + return engine.ErrWorkflowFailed 366 + } 367 + 368 + return nil 369 + } 370 + 371 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 372 + if wfLogger == nil { 373 + return nil 374 + } 375 + 376 + // This actually *starts* the command. Thanks, Docker! 377 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 378 + if err != nil { 379 + return err 380 + } 381 + defer logs.Close() 382 + 383 + _, err = stdcopy.StdCopy( 384 + wfLogger.DataWriter("stdout"), 385 + wfLogger.DataWriter("stderr"), 386 + logs.Reader, 387 + ) 388 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 389 + return fmt.Errorf("failed to copy logs: %w", err) 390 + } 391 + 392 + return nil 393 + } 394 + 395 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 396 + e.cleanupMu.Lock() 397 + key := wid.String() 398 + 399 + fns := e.cleanup[key] 400 + delete(e.cleanup, key) 401 + e.cleanupMu.Unlock() 402 + 403 + for _, fn := range fns { 404 + if err := fn(ctx); err != nil { 405 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 406 + } 407 + } 408 + return nil 409 + } 410 + 411 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 412 + e.cleanupMu.Lock() 413 + defer e.cleanupMu.Unlock() 414 + 415 + key := wid.String() 416 + e.cleanup[key] = append(e.cleanup[key], fn) 417 + } 418 + 419 + func networkName(wid models.WorkflowId) string { 420 + return fmt.Sprintf("workflow-network-%s", wid) 421 + }
+28
spindle/engines/nixery/envs.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
···
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+168 -10
spindle/ingester.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 - "path/filepath" 8 9 "tangled.sh/tangled.sh/core/api/tangled" 10 "tangled.sh/tangled.sh/core/eventconsumer" 11 "tangled.sh/tangled.sh/core/rbac" 12 13 "github.com/bluesky-social/jetstream/pkg/models" 14 ) 15 16 type Ingester func(ctx context.Context, e *models.Event) error ··· 32 33 switch e.Commit.Collection { 34 case tangled.SpindleMemberNSID: 35 - s.ingestMember(ctx, e) 36 case tangled.RepoNSID: 37 - s.ingestRepo(ctx, e) 38 } 39 40 - return err 41 } 42 } 43 44 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 45 did := e.Did 46 - var err error 47 48 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 49 ··· 58 } 59 60 domain := s.cfg.Server.Hostname 61 - if s.cfg.Server.Dev { 62 - domain = s.cfg.Server.ListenAddr 63 - } 64 recordInstance := record.Instance 65 66 if recordInstance != domain { ··· 74 return fmt.Errorf("failed to enforce permissions: %w", err) 75 } 76 77 if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 78 l.Error("failed to add member", "error", err) 79 return fmt.Errorf("failed to add member: %w", err) ··· 88 89 return nil 90 91 } 92 return nil 93 } 94 95 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 96 var err error 97 98 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 99 ··· 129 return fmt.Errorf("failed to add repo: %w", err) 130 } 131 132 // add repo to rbac 133 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil { 134 l.Error("failed to add repo to enforcer", "error", err) 135 return fmt.Errorf("failed to add repo: %w", err) 136 } 137 138 // add this knot to the event consumer 139 src := eventconsumer.NewKnotSource(record.Knot) 140 s.ks.AddSource(context.Background(), src) ··· 144 } 145 return nil 146 }
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 + "time" 9 10 "tangled.sh/tangled.sh/core/api/tangled" 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" 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" 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 22 ) 23 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 40 41 switch e.Commit.Collection { 42 case tangled.SpindleMemberNSID: 43 + err = s.ingestMember(ctx, e) 44 case tangled.RepoNSID: 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) 52 } 53 54 + return nil 55 } 56 } 57 58 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 59 + var err error 60 did := e.Did 61 + rkey := e.Commit.RKey 62 63 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 64 ··· 73 } 74 75 domain := s.cfg.Server.Hostname 76 recordInstance := record.Instance 77 78 if recordInstance != domain { ··· 86 return fmt.Errorf("failed to enforce permissions: %w", err) 87 } 88 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 { 101 l.Error("failed to add member", "error", err) 102 return fmt.Errorf("failed to add member: %w", err) ··· 111 112 return nil 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 + 138 } 139 return nil 140 } 141 142 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 var err error 144 + did := e.Did 145 + resolver := idresolver.DefaultResolver() 146 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 ··· 178 return fmt.Errorf("failed to add repo: %w", err) 179 } 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 + 201 // add this knot to the event consumer 202 src := eventconsumer.NewKnotSource(record.Knot) 203 s.ks.AddSource(context.Background(), src) ··· 207 } 208 return nil 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 func NewControlLogLine(idx int, step Step) LogLine { 105 return LogLine{ 106 Kind: LogKindControl, 107 - Content: step.Name, 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 111 } 112 }
··· 104 func NewControlLogLine(idx int, step Step) LogLine { 105 return LogLine{ 106 Kind: LogKindControl, 107 + Content: step.Name(), 108 StepId: idx, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 } 112 }
+8 -103
spindle/models/pipeline.go
··· 1 package models 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 type Pipeline struct { 11 RepoOwner string 12 RepoName string 13 - Workflows []Workflow 14 } 15 16 - type Step struct { 17 - Command string 18 - Name string 19 - Environment map[string]string 20 - Kind StepKind 21 } 22 23 type StepKind int ··· 30 ) 31 32 type Workflow struct { 33 - Steps []Step 34 - Environment map[string]string 35 - Name string 36 - Image string 37 - } 38 - 39 - // setupSteps get added to start of Steps 40 - type setupSteps []Step 41 - 42 - // addStep adds a step to the beginning of the workflow's steps. 43 - func (ss *setupSteps) addStep(step Step) { 44 - *ss = append(*ss, step) 45 - } 46 - 47 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 48 - // In the process, dependencies are resolved: nixpkgs deps 49 - // are constructed atop nixery and set as the Workflow.Image, 50 - // and ones from custom registries 51 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 52 - workflows := []Workflow{} 53 - 54 - for _, twf := range pl.Workflows { 55 - swf := &Workflow{} 56 - for _, tstep := range twf.Steps { 57 - sstep := Step{} 58 - sstep.Environment = stepEnvToMap(tstep.Environment) 59 - sstep.Command = tstep.Command 60 - sstep.Name = tstep.Name 61 - sstep.Kind = StepKindUser 62 - swf.Steps = append(swf.Steps, sstep) 63 - } 64 - swf.Name = twf.Name 65 - swf.Environment = workflowEnvToMap(twf.Environment) 66 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 - 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - repoOwner := pl.TriggerMetadata.Repo.Did 83 - repoName := pl.TriggerMetadata.Repo.Repo 84 - return &Pipeline{ 85 - RepoOwner: repoOwner, 86 - RepoName: repoName, 87 - Workflows: workflows, 88 - } 89 - } 90 - 91 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 92 - envMap := map[string]string{} 93 - for _, env := range envs { 94 - if env != nil { 95 - envMap[env.Key] = env.Value 96 - } 97 - } 98 - return envMap 99 - } 100 - 101 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 102 - envMap := map[string]string{} 103 - for _, env := range envs { 104 - if env != nil { 105 - envMap[env.Key] = env.Value 106 - } 107 - } 108 - return envMap 109 - } 110 - 111 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 112 - var dependencies string 113 - for _, d := range deps { 114 - if d.Registry == "nixpkgs" { 115 - dependencies = path.Join(d.Packages...) 116 - } 117 - } 118 - 119 - // load defaults from somewhere else 120 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 121 - 122 - return path.Join(nixery, dependencies) 123 }
··· 1 package models 2 3 type Pipeline struct { 4 RepoOwner string 5 RepoName string 6 + Workflows map[Engine][]Workflow 7 } 8 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 13 } 14 15 type StepKind int ··· 22 ) 23 24 type Workflow struct { 25 + Steps []Step 26 + Name string 27 + Data any 28 }
-128
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 - ) 11 - 12 - func nixConfStep() Step { 13 - setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 14 - echo 'build-users-group = ' >> /etc/nix/nix.conf` 15 - return Step{ 16 - Command: setupCmd, 17 - Name: "Configure Nix", 18 - } 19 - } 20 - 21 - // cloneOptsAsSteps processes clone options and adds corresponding steps 22 - // to the beginning of the workflow's step list if cloning is not skipped. 23 - // 24 - // the steps to do here are: 25 - // - git init 26 - // - git remote add origin <url> 27 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 28 - // - git checkout FETCH_HEAD 29 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 30 - if twf.Clone.Skip { 31 - return Step{} 32 - } 33 - 34 - var commands []string 35 - 36 - // initialize git repo in workspace 37 - commands = append(commands, "git init") 38 - 39 - // add repo as git remote 40 - scheme := "https://" 41 - if dev { 42 - scheme = "http://" 43 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 44 - } 45 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 46 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 47 - 48 - // run git fetch 49 - { 50 - var fetchArgs []string 51 - 52 - // default clone depth is 1 53 - depth := 1 54 - if twf.Clone.Depth > 1 { 55 - depth = int(twf.Clone.Depth) 56 - } 57 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 58 - 59 - // optionally recurse submodules 60 - if twf.Clone.Submodules { 61 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 62 - } 63 - 64 - // set remote to fetch from 65 - fetchArgs = append(fetchArgs, "origin") 66 - 67 - // set revision to checkout 68 - switch workflow.TriggerKind(tr.Kind) { 69 - case workflow.TriggerKindManual: 70 - // TODO: unimplemented 71 - case workflow.TriggerKindPush: 72 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 73 - case workflow.TriggerKindPullRequest: 74 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 75 - } 76 - 77 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 78 - } 79 - 80 - // run git checkout 81 - commands = append(commands, "git checkout FETCH_HEAD") 82 - 83 - cloneStep := Step{ 84 - Command: strings.Join(commands, "\n"), 85 - Name: "Clone repository into workspace", 86 - } 87 - return cloneStep 88 - } 89 - 90 - // dependencyStep processes dependencies defined in the workflow. 91 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 92 - // all packages and adds a single 'nix profile install' step to the 93 - // beginning of the workflow's step list. 94 - func dependencyStep(twf tangled.Pipeline_Workflow) *Step { 95 - var customPackages []string 96 - 97 - for _, d := range twf.Dependencies { 98 - registry := d.Registry 99 - packages := d.Packages 100 - 101 - if registry == "nixpkgs" { 102 - continue 103 - } 104 - 105 - if len(packages) == 0 { 106 - customPackages = append(customPackages, registry) 107 - } 108 - // collect packages from custom registries 109 - for _, pkg := range packages { 110 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 111 - } 112 - } 113 - 114 - if len(customPackages) > 0 { 115 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 116 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 117 - installStep := Step{ 118 - Command: cmd, 119 - Name: "Install custom dependencies", 120 - Environment: map[string]string{ 121 - "NIX_NO_COLOR": "1", 122 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 123 - }, 124 - } 125 - return &installStep 126 - } 127 - return nil 128 - }
···
+56 -150
spindle/secrets/openbao.go
··· 6 "log/slog" 7 "path" 8 "strings" 9 - "sync" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" ··· 16 type OpenBaoManager struct { 17 client *vault.Client 18 mountPath string 19 - roleID string 20 - secretID string 21 - stopCh chan struct{} 22 - tokenMu sync.RWMutex 23 logger *slog.Logger 24 } 25 ··· 31 } 32 } 33 34 - func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 35 - if address == "" { 36 - return nil, fmt.Errorf("address cannot be empty") 37 - } 38 - if roleID == "" { 39 - return nil, fmt.Errorf("role_id cannot be empty") 40 - } 41 - if secretID == "" { 42 - return nil, fmt.Errorf("secret_id cannot be empty") 43 } 44 45 config := vault.DefaultConfig() 46 - config.Address = address 47 48 client, err := vault.NewClient(config) 49 if err != nil { 50 return nil, fmt.Errorf("failed to create openbao client: %w", err) 51 } 52 53 - // Authenticate using AppRole 54 - err = authenticateAppRole(client, roleID, secretID) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err) 57 - } 58 - 59 manager := &OpenBaoManager{ 60 client: client, 61 mountPath: "spindle", // default KV v2 mount path 62 - roleID: roleID, 63 - secretID: secretID, 64 - stopCh: make(chan struct{}), 65 logger: logger, 66 } 67 ··· 69 opt(manager) 70 } 71 72 - go manager.tokenRenewalLoop() 73 - 74 - return manager, nil 75 - } 76 - 77 - // authenticateAppRole authenticates the client using AppRole method 78 - func authenticateAppRole(client *vault.Client, roleID, secretID string) error { 79 - authData := map[string]interface{}{ 80 - "role_id": roleID, 81 - "secret_id": secretID, 82 - } 83 - 84 - resp, err := client.Logical().Write("auth/approle/login", authData) 85 - if err != nil { 86 - return fmt.Errorf("failed to login with AppRole: %w", err) 87 - } 88 - 89 - if resp == nil || resp.Auth == nil { 90 - return fmt.Errorf("no auth info returned from AppRole login") 91 - } 92 - 93 - client.SetToken(resp.Auth.ClientToken) 94 - return nil 95 - } 96 - 97 - // stop stops the token renewal goroutine 98 - func (v *OpenBaoManager) Stop() { 99 - close(v.stopCh) 100 - } 101 - 102 - // tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens 103 - func (v *OpenBaoManager) tokenRenewalLoop() { 104 - ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds 105 - defer ticker.Stop() 106 - 107 - for { 108 - select { 109 - case <-v.stopCh: 110 - return 111 - case <-ticker.C: 112 - ctx := context.Background() 113 - if err := v.ensureValidToken(ctx); err != nil { 114 - v.logger.Error("openbao token renewal failed", "error", err) 115 - } 116 - } 117 - } 118 - } 119 - 120 - // ensureValidToken checks if the current token is valid and renews or re-authenticates if needed 121 - func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error { 122 - v.tokenMu.Lock() 123 - defer v.tokenMu.Unlock() 124 - 125 - // check current token info 126 - tokenInfo, err := v.client.Auth().Token().LookupSelf() 127 - if err != nil { 128 - // token is invalid, need to re-authenticate 129 - v.logger.Warn("token lookup failed, re-authenticating", "error", err) 130 - return v.reAuthenticate() 131 - } 132 - 133 - if tokenInfo == nil || tokenInfo.Data == nil { 134 - return v.reAuthenticate() 135 - } 136 - 137 - // check TTL 138 - ttlRaw, ok := tokenInfo.Data["ttl"] 139 - if !ok { 140 - return v.reAuthenticate() 141 - } 142 - 143 - var ttl int64 144 - switch t := ttlRaw.(type) { 145 - case int64: 146 - ttl = t 147 - case float64: 148 - ttl = int64(t) 149 - case int: 150 - ttl = int64(t) 151 - default: 152 - return v.reAuthenticate() 153 - } 154 - 155 - // if TTL is less than 5 minutes, try to renew 156 - if ttl < 300 { 157 - v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl) 158 - 159 - renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h 160 - if err != nil { 161 - v.logger.Warn("token renewal failed, re-authenticating", "error", err) 162 - return v.reAuthenticate() 163 - } 164 - 165 - if renewResp == nil || renewResp.Auth == nil { 166 - v.logger.Warn("token renewal returned no auth info, re-authenticating") 167 - return v.reAuthenticate() 168 - } 169 - 170 - v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration) 171 } 172 173 - return nil 174 } 175 176 - // reAuthenticate performs a fresh authentication using AppRole 177 - func (v *OpenBaoManager) reAuthenticate() error { 178 - v.logger.Info("re-authenticating with approle") 179 180 - err := authenticateAppRole(v.client, v.roleID, v.secretID) 181 if err != nil { 182 - return fmt.Errorf("re-authentication failed: %w", err) 183 } 184 185 - v.logger.Info("re-authentication successful") 186 return nil 187 } 188 189 func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 190 - v.tokenMu.RLock() 191 - defer v.tokenMu.RUnlock() 192 if err := ValidateKey(secret.Key); err != nil { 193 return err 194 } 195 196 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 197 - 198 - fmt.Println(v.mountPath, secretPath) 199 200 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 201 if err == nil && existing != nil { 202 return ErrKeyAlreadyPresent 203 } 204 ··· 210 "created_by": secret.CreatedBy.String(), 211 } 212 213 - _, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 214 if err != nil { 215 return fmt.Errorf("failed to store secret in openbao: %w", err) 216 } 217 218 return nil 219 } 220 221 func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 222 - v.tokenMu.RLock() 223 - defer v.tokenMu.RUnlock() 224 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 225 226 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 227 if err != nil || existing == nil { 228 return ErrKeyNotFound 229 } 230 231 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 232 if err != nil { 233 return fmt.Errorf("failed to delete secret from openbao: %w", err) 234 } 235 236 return nil 237 } 238 239 func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 240 - v.tokenMu.RLock() 241 - defer v.tokenMu.RUnlock() 242 repoPath := v.buildRepoPath(repo) 243 244 - secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 245 if err != nil { 246 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 247 return []LockedSecret{}, nil ··· 266 continue 267 } 268 269 - secretPath := path.Join(repoPath, key) 270 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 271 if err != nil { 272 - continue // Skip secrets we can't read 273 } 274 275 if secretData == nil || secretData.Data == nil { ··· 308 secrets = append(secrets, secret) 309 } 310 311 return secrets, nil 312 } 313 314 func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 315 - v.tokenMu.RLock() 316 - defer v.tokenMu.RUnlock() 317 repoPath := v.buildRepoPath(repo) 318 319 - secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 320 if err != nil { 321 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 322 return []UnlockedSecret{}, nil ··· 341 continue 342 } 343 344 - secretPath := path.Join(repoPath, key) 345 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 346 if err != nil { 347 continue 348 } 349 ··· 355 356 valueStr, ok := data["value"].(string) 357 if !ok { 358 - continue // skip secrets without values 359 } 360 361 createdAtStr, ok := data["created_at"].(string) ··· 389 secrets = append(secrets, secret) 390 } 391 392 return secrets, nil 393 } 394 395 - // buildRepoPath creates an OpenBao path for a repository 396 func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 397 // convert DidSlashRepo to a safe path by replacing special characters 398 repoPath := strings.ReplaceAll(string(repo), "/", "_") ··· 401 return fmt.Sprintf("repos/%s", repoPath) 402 } 403 404 - // buildSecretPath creates an OpenBao path for a specific secret 405 func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 406 return path.Join(v.buildRepoPath(repo), key) 407 }
··· 6 "log/slog" 7 "path" 8 "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 type OpenBaoManager struct { 16 client *vault.Client 17 mountPath string 18 logger *slog.Logger 19 } 20 ··· 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 ··· 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 ··· 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 ··· 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 { ··· 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 ··· 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 ··· 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) ··· 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), "/", "_") ··· 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 }
+59 -84
spindle/secrets/openbao_test.go
··· 16 secrets map[string]UnlockedSecret // key: repo_key format 17 shouldError bool 18 errorToReturn error 19 - stopped bool 20 } 21 22 func NewMockOpenBaoManager() *MockOpenBaoManager { ··· 31 func (m *MockOpenBaoManager) ClearError() { 32 m.shouldError = false 33 m.errorToReturn = nil 34 - } 35 - 36 - func (m *MockOpenBaoManager) Stop() { 37 - m.stopped = true 38 - } 39 - 40 - func (m *MockOpenBaoManager) IsStopped() bool { 41 - return m.stopped 42 } 43 44 func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { ··· 118 } 119 } 120 121 func TestOpenBaoManagerInterface(t *testing.T) { 122 var _ Manager = (*OpenBaoManager)(nil) 123 } ··· 125 func TestNewOpenBaoManager(t *testing.T) { 126 tests := []struct { 127 name string 128 - address string 129 - roleID string 130 - secretID string 131 opts []OpenBaoManagerOpt 132 expectError bool 133 errorContains string 134 }{ 135 { 136 - name: "empty address", 137 - address: "", 138 - roleID: "test-role-id", 139 - secretID: "test-secret-id", 140 opts: nil, 141 expectError: true, 142 - errorContains: "address cannot be empty", 143 }, 144 { 145 - name: "empty role_id", 146 - address: "http://localhost:8200", 147 - roleID: "", 148 - secretID: "test-secret-id", 149 opts: nil, 150 - expectError: true, 151 - errorContains: "role_id cannot be empty", 152 }, 153 { 154 - name: "empty secret_id", 155 - address: "http://localhost:8200", 156 - roleID: "test-role-id", 157 - secretID: "", 158 - opts: nil, 159 - expectError: true, 160 - errorContains: "secret_id cannot be empty", 161 }, 162 } 163 164 for _, tt := range tests { 165 t.Run(tt.name, func(t *testing.T) { 166 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 167 - manager, err := NewOpenBaoManager(tt.address, tt.roleID, tt.secretID, logger, tt.opts...) 168 169 if tt.expectError { 170 assert.Error(t, err) 171 assert.Nil(t, manager) 172 assert.Contains(t, err.Error(), tt.errorContains) 173 } else { 174 - // For valid configurations, we expect an error during authentication 175 - // since we're not connecting to a real OpenBao server 176 - assert.Error(t, err) 177 - assert.Nil(t, manager) 178 } 179 }) 180 } ··· 253 assert.Equal(t, "custom-mount", manager.mountPath) 254 } 255 256 - func TestOpenBaoManager_Stop(t *testing.T) { 257 - // Create a manager with minimal setup 258 - manager := &OpenBaoManager{ 259 - mountPath: "test", 260 - stopCh: make(chan struct{}), 261 - } 262 - 263 - // Verify the manager implements Stopper interface 264 - var stopper Stopper = manager 265 - assert.NotNil(t, stopper) 266 - 267 - // Call Stop and verify it doesn't panic 268 - assert.NotPanics(t, func() { 269 - manager.Stop() 270 - }) 271 - 272 - // Verify the channel was closed 273 - select { 274 - case <-manager.stopCh: 275 - // Channel was closed as expected 276 - default: 277 - t.Error("Expected stop channel to be closed after Stop()") 278 - } 279 - } 280 - 281 - func TestOpenBaoManager_StopperInterface(t *testing.T) { 282 - manager := &OpenBaoManager{} 283 - 284 - // Verify that OpenBaoManager implements the Stopper interface 285 - _, ok := interface{}(manager).(Stopper) 286 - assert.True(t, ok, "OpenBaoManager should implement Stopper interface") 287 - } 288 - 289 - // Test MockOpenBaoManager interface compliance 290 - func TestMockOpenBaoManagerInterface(t *testing.T) { 291 - var _ Manager = (*MockOpenBaoManager)(nil) 292 - var _ Stopper = (*MockOpenBaoManager)(nil) 293 - } 294 - 295 func TestMockOpenBaoManager_AddSecret(t *testing.T) { 296 tests := []struct { 297 name string ··· 563 assert.NoError(t, err) 564 } 565 566 - func TestMockOpenBaoManager_Stop(t *testing.T) { 567 - mock := NewMockOpenBaoManager() 568 - 569 - assert.False(t, mock.IsStopped()) 570 - 571 - mock.Stop() 572 - 573 - assert.True(t, mock.IsStopped()) 574 - } 575 - 576 func TestMockOpenBaoManager_Integration(t *testing.T) { 577 tests := []struct { 578 name string ··· 628 }) 629 } 630 }
··· 16 secrets map[string]UnlockedSecret // key: repo_key format 17 shouldError bool 18 errorToReturn error 19 } 20 21 func NewMockOpenBaoManager() *MockOpenBaoManager { ··· 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 { ··· 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 } ··· 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 } ··· 239 assert.Equal(t, "custom-mount", manager.mountPath) 240 } 241 242 func TestMockOpenBaoManager_AddSecret(t *testing.T) { 243 tests := []struct { 244 name string ··· 510 assert.NoError(t, err) 511 } 512 513 func TestMockOpenBaoManager_Integration(t *testing.T) { 514 tests := []struct { 515 name string ··· 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 + }
+13 -6
spindle/secrets/policy.hcl
··· 1 - # KV v2 data operations 2 - path "spindle/data/*" { 3 capabilities = ["create", "read", "update", "delete", "list"] 4 } 5 6 - # KV v2 metadata operations (needed for listing) 7 path "spindle/metadata/*" { 8 capabilities = ["list", "read", "delete"] 9 } 10 11 - # Root path access (needed for mount-level operations) 12 - path "spindle/*" { 13 - capabilities = ["list"] 14 } 15
··· 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 + }
+1 -1
spindle/secrets/sqlite.go
··· 24 } 25 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 - db, err := sql.Open("sqlite3", dbPath) 28 if err != nil { 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 }
··· 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 }
+68 -30
spindle/server.go
··· 20 "tangled.sh/tangled.sh/core/spindle/config" 21 "tangled.sh/tangled.sh/core/spindle/db" 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 "tangled.sh/tangled.sh/core/spindle/models" 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 "tangled.sh/tangled.sh/core/spindle/secrets" 26 "tangled.sh/tangled.sh/core/spindle/xrpc" 27 ) 28 29 //go:embed motd ··· 39 e *rbac.Enforcer 40 l *slog.Logger 41 n *notifier.Notifier 42 - eng *engine.Engine 43 jq *queue.Queue 44 cfg *config.Config 45 ks *eventconsumer.Consumer ··· 71 var vault secrets.Manager 72 switch cfg.Server.Secrets.Provider { 73 case "openbao": 74 - if cfg.Server.Secrets.OpenBao.Addr == "" { 75 - return fmt.Errorf("openbao address is required when using openbao secrets provider") 76 - } 77 - if cfg.Server.Secrets.OpenBao.RoleID == "" { 78 - return fmt.Errorf("openbao role_id is required when using openbao secrets provider") 79 - } 80 - if cfg.Server.Secrets.OpenBao.SecretID == "" { 81 - return fmt.Errorf("openbao secret_id is required when using openbao secrets provider") 82 } 83 vault, err = secrets.NewOpenBaoManager( 84 - cfg.Server.Secrets.OpenBao.Addr, 85 - cfg.Server.Secrets.OpenBao.RoleID, 86 - cfg.Server.Secrets.OpenBao.SecretID, 87 logger, 88 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 89 ) 90 if err != nil { 91 return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 92 } 93 - logger.Info("using openbao secrets provider", "address", cfg.Server.Secrets.OpenBao.Addr, "mount", cfg.Server.Secrets.OpenBao.Mount) 94 case "sqlite", "": 95 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 96 if err != nil { ··· 101 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 102 } 103 104 - eng, err := engine.New(ctx, cfg, d, &n, vault) 105 if err != nil { 106 return err 107 } 108 109 - jq := queue.NewQueue(100, 2) 110 111 collections := []string{ 112 tangled.SpindleMemberNSID, 113 tangled.RepoNSID, 114 } 115 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 116 if err != nil { ··· 118 } 119 jc.AddDid(cfg.Server.Owner) 120 121 resolver := idresolver.DefaultResolver() 122 123 spindle := Spindle{ ··· 126 db: d, 127 l: logger, 128 n: &n, 129 - eng: eng, 130 jq: jq, 131 cfg: cfg, 132 res: resolver, ··· 198 w.Write(motd) 199 }) 200 mux.HandleFunc("/events", s.Events) 201 - mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 202 - w.Write([]byte(s.cfg.Server.Owner)) 203 - }) 204 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 205 206 mux.Mount("/xrpc", s.XrpcRouter()) ··· 210 func (s *Spindle) XrpcRouter() http.Handler { 211 logger := s.l.With("route", "xrpc") 212 213 x := xrpc.Xrpc{ 214 - Logger: logger, 215 - Db: s.db, 216 - Enforcer: s.e, 217 - Engine: s.eng, 218 - Config: s.cfg, 219 - Resolver: s.res, 220 - Vault: s.vault, 221 } 222 223 return x.Router() ··· 240 return fmt.Errorf("no repo data found") 241 } 242 243 // filter by repos 244 _, err = s.db.GetRepo( 245 tpl.TriggerMetadata.Repo.Knot, ··· 255 Rkey: msg.Rkey, 256 } 257 258 for _, w := range tpl.Workflows { 259 if w != nil { 260 - err := s.db.StatusPending(models.WorkflowId{ 261 PipelineId: pipelineId, 262 Name: w.Name, 263 }, s.n) ··· 266 } 267 } 268 } 269 - 270 - spl := models.ToPipeline(tpl, *s.cfg) 271 272 ok := s.jq.Enqueue(queue.Job{ 273 Run: func() error { 274 - s.eng.StartWorkflows(ctx, spl, pipelineId) 275 return nil 276 }, 277 OnFail: func(jobError error) {
··· 20 "tangled.sh/tangled.sh/core/spindle/config" 21 "tangled.sh/tangled.sh/core/spindle/db" 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 + "tangled.sh/tangled.sh/core/spindle/engines/nixery" 24 "tangled.sh/tangled.sh/core/spindle/models" 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 "tangled.sh/tangled.sh/core/spindle/secrets" 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 29 ) 30 31 //go:embed motd ··· 41 e *rbac.Enforcer 42 l *slog.Logger 43 n *notifier.Notifier 44 + engs map[string]models.Engine 45 jq *queue.Queue 46 cfg *config.Config 47 ks *eventconsumer.Consumer ··· 73 var vault secrets.Manager 74 switch cfg.Server.Secrets.Provider { 75 case "openbao": 76 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 } 79 vault, err = secrets.NewOpenBaoManager( 80 + cfg.Server.Secrets.OpenBao.ProxyAddr, 81 logger, 82 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 ) 84 if err != nil { 85 return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 } 87 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 case "sqlite", "": 89 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 if err != nil { ··· 95 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 } 97 98 + nixeryEng, err := nixery.New(ctx, cfg) 99 if err != nil { 100 return err 101 } 102 103 + jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 + logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 105 106 collections := []string{ 107 tangled.SpindleMemberNSID, 108 tangled.RepoNSID, 109 + tangled.RepoCollaboratorNSID, 110 } 111 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 112 if err != nil { ··· 114 } 115 jc.AddDid(cfg.Server.Owner) 116 117 + // Check if the spindle knows about any Dids; 118 + dids, err := d.GetAllDids() 119 + if err != nil { 120 + return fmt.Errorf("failed to get all dids: %w", err) 121 + } 122 + for _, d := range dids { 123 + jc.AddDid(d) 124 + } 125 + 126 resolver := idresolver.DefaultResolver() 127 128 spindle := Spindle{ ··· 131 db: d, 132 l: logger, 133 n: &n, 134 + engs: map[string]models.Engine{"nixery": nixeryEng}, 135 jq: jq, 136 cfg: cfg, 137 res: resolver, ··· 203 w.Write(motd) 204 }) 205 mux.HandleFunc("/events", s.Events) 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 207 208 mux.Mount("/xrpc", s.XrpcRouter()) ··· 212 func (s *Spindle) XrpcRouter() http.Handler { 213 logger := s.l.With("route", "xrpc") 214 215 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 + 217 x := xrpc.Xrpc{ 218 + Logger: logger, 219 + Db: s.db, 220 + Enforcer: s.e, 221 + Engines: s.engs, 222 + Config: s.cfg, 223 + Resolver: s.res, 224 + Vault: s.vault, 225 + ServiceAuth: serviceAuth, 226 } 227 228 return x.Router() ··· 245 return fmt.Errorf("no repo data found") 246 } 247 248 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 249 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 250 + } 251 + 252 // filter by repos 253 _, err = s.db.GetRepo( 254 tpl.TriggerMetadata.Repo.Knot, ··· 264 Rkey: msg.Rkey, 265 } 266 267 + workflows := make(map[models.Engine][]models.Workflow) 268 + 269 for _, w := range tpl.Workflows { 270 if w != nil { 271 + if _, ok := s.engs[w.Engine]; !ok { 272 + err = s.db.StatusFailed(models.WorkflowId{ 273 + PipelineId: pipelineId, 274 + Name: w.Name, 275 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 276 + if err != nil { 277 + return err 278 + } 279 + 280 + continue 281 + } 282 + 283 + eng := s.engs[w.Engine] 284 + 285 + if _, ok := workflows[eng]; !ok { 286 + workflows[eng] = []models.Workflow{} 287 + } 288 + 289 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 290 + if err != nil { 291 + return err 292 + } 293 + 294 + workflows[eng] = append(workflows[eng], *ewf) 295 + 296 + err = s.db.StatusPending(models.WorkflowId{ 297 PipelineId: pipelineId, 298 Name: w.Name, 299 }, s.n) ··· 302 } 303 } 304 } 305 306 ok := s.jq.Enqueue(queue.Job{ 307 Run: func() error { 308 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 + RepoName: tpl.TriggerMetadata.Repo.Repo, 311 + Workflows: workflows, 312 + }, pipelineId) 313 return nil 314 }, 315 OnFail: func(jobError error) {
+32 -2
spindle/stream.go
··· 6 "fmt" 7 "io" 8 "net/http" 9 "strconv" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/spindle/engine" 13 "tangled.sh/tangled.sh/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" ··· 143 } 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 146 - filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid) 147 148 config := tail.Config{ 149 Follow: !isFinished,
··· 6 "fmt" 7 "io" 8 "net/http" 9 + "os" 10 "strconv" 11 "time" 12 13 "tangled.sh/tangled.sh/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" ··· 143 } 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 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 + } 177 178 config := tail.Config{ 179 Follow: !isFinished,
+11 -10
spindle/xrpc/add_secret.go
··· 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 ··· 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
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 return 30 } 31 32 var data tangled.RepoAddSecret_Input 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 return 36 } 37 38 if err := secrets.ValidateKey(data.Key); err != nil { 39 + fail(xrpcerr.GenericError(err)) 40 return 41 } 42 43 // unfortunately we have to resolve repo-at here 44 repoAt, err := syntax.ParseATURI(data.Repo) 45 if err != nil { 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 47 return 48 } 49 50 // resolve this aturi to extract the repo record 51 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 if err != nil || ident.Handle.IsInvalidHandle() { 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 return 55 } 56 57 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 return 62 } 63 64 repo := resp.Value.Val.(*tangled.Repo) 65 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 return 69 } 70 71 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 l.Error("insufficent permissions", "did", actorDid.String()) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 return 75 } 76 ··· 84 err = x.Vault.AddSecret(r.Context(), secret) 85 if err != nil { 86 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 88 return 89 } 90
+10 -9
spindle/xrpc/list_secrets.go
··· 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
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 return 30 } 31 32 repoParam := r.URL.Query().Get("repo") 33 if repoParam == "" { 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 35 return 36 } 37 38 // unfortunately we have to resolve repo-at here 39 repoAt, err := syntax.ParseATURI(repoParam) 40 if err != nil { 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 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(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 return 50 } 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 return 57 } 58 59 repo := resp.Value.Val.(*tangled.Repo) 60 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 return 64 } 65 66 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 return 70 } 71 72 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 if err != nil { 74 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 76 return 77 } 78
+31
spindle/xrpc/owner.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+10 -9
spindle/xrpc/remove_secret.go
··· 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 ··· 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
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 ) 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 + fail := func(e xrpcerr.XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 return 29 } 30 31 var data tangled.RepoRemoveSecret_Input 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 return 35 } 36 37 // unfortunately we have to resolve repo-at here 38 repoAt, err := syntax.ParseATURI(data.Repo) 39 if err != nil { 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 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(xrpcerr.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(xrpcerr.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(xrpcerr.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, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 return 69 } 70 ··· 75 err = x.Vault.RemoveSecret(r.Context(), secret) 76 if err != nil { 77 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 79 return 80 } 81
+20 -108
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" ··· 17 "tangled.sh/tangled.sh/core/rbac" 18 "tangled.sh/tangled.sh/core/spindle/config" 19 "tangled.sh/tangled.sh/core/spindle/db" 20 - "tangled.sh/tangled.sh/core/spindle/engine" 21 "tangled.sh/tangled.sh/core/spindle/secrets" 22 ) 23 24 const ActorDid string = "ActorDid" 25 26 type Xrpc struct { 27 - Logger *slog.Logger 28 - Db *db.DB 29 - Enforcer *rbac.Enforcer 30 - Engine *engine.Engine 31 - Config *config.Config 32 - Resolver *idresolver.Resolver 33 - Vault secrets.Manager 34 } 35 36 func (x *Xrpc) Router() http.Handler { 37 r := chi.NewRouter() 38 39 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 - r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 - 43 - return r 44 - } 45 - 46 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 - l := x.Logger.With("url", r.URL) 49 - 50 - token := r.Header.Get("Authorization") 51 - token = strings.TrimPrefix(token, "Bearer ") 52 - 53 - s := auth.ServiceAuthValidator{ 54 - Audience: x.Config.Server.Did().String(), 55 - Dir: x.Resolver.Directory(), 56 - } 57 - 58 - did, err := s.Validate(r.Context(), token, nil) 59 - if err != nil { 60 - l.Error("signature verification failed", "err", err) 61 - writeError(w, AuthError(err), http.StatusForbidden) 62 - return 63 - } 64 - 65 - r = r.WithContext( 66 - context.WithValue(r.Context(), ActorDid, did), 67 - ) 68 69 - next.ServeHTTP(w, r) 70 }) 71 - } 72 - 73 - type XrpcError struct { 74 - Tag string `json:"error"` 75 - Message string `json:"message"` 76 - } 77 78 - func NewXrpcError(opts ...ErrOpt) XrpcError { 79 - x := XrpcError{} 80 - for _, o := range opts { 81 - o(&x) 82 - } 83 - 84 - return x 85 - } 86 - 87 - type ErrOpt = func(xerr *XrpcError) 88 - 89 - func WithTag(tag string) ErrOpt { 90 - return func(xerr *XrpcError) { 91 - xerr.Tag = tag 92 - } 93 - } 94 - 95 - func WithMessage[S ~string](s S) ErrOpt { 96 - return func(xerr *XrpcError) { 97 - xerr.Message = string(s) 98 - } 99 - } 100 - 101 - func WithError(e error) ErrOpt { 102 - return func(xerr *XrpcError) { 103 - xerr.Message = e.Error() 104 - } 105 - } 106 - 107 - var MissingActorDidError = NewXrpcError( 108 - WithTag("MissingActorDid"), 109 - WithMessage("actor DID not supplied"), 110 - ) 111 - 112 - var AuthError = func(err error) XrpcError { 113 - return NewXrpcError( 114 - WithTag("Auth"), 115 - WithError(fmt.Errorf("signature verification failed: %w", err)), 116 - ) 117 - } 118 - 119 - var InvalidRepoError = func(r string) XrpcError { 120 - return NewXrpcError( 121 - WithTag("InvalidRepo"), 122 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 - ) 124 - } 125 - 126 - func GenericError(err error) XrpcError { 127 - return NewXrpcError( 128 - WithTag("Generic"), 129 - WithError(err), 130 - ) 131 - } 132 133 - var AccessControlError = func(d string) XrpcError { 134 - return NewXrpcError( 135 - WithTag("AccessControl"), 136 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 - ) 138 } 139 140 // this is slightly different from http_util::write_error to follow the spec: 141 // 142 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 144 w.Header().Set("Content-Type", "application/json") 145 w.WriteHeader(status) 146 json.NewEncoder(w).Encode(e)
··· 1 package xrpc 2 3 import ( 4 _ "embed" 5 "encoding/json" 6 "log/slog" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/config" 15 "tangled.sh/tangled.sh/core/spindle/db" 16 + "tangled.sh/tangled.sh/core/spindle/models" 17 "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 ) 21 22 const ActorDid string = "ActorDid" 23 24 type Xrpc struct { 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 33 } 34 35 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 37 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 40 41 + r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 + r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 + r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 44 }) 45 46 + // service query endpoints (no auth required) 47 + r.Get("/"+tangled.OwnerNSID, x.Owner) 48 49 + return r 50 } 51 52 // this is slightly different from http_util::write_error to follow the spec: 53 // 54 // the json object returned must include an "error" and a "message" 55 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 56 w.Header().Set("Content-Type", "application/json") 57 w.WriteHeader(status) 58 json.NewEncoder(w).Encode(e)
+1 -3
tailwind.config.js
··· 36 css: { 37 maxWidth: "none", 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": {}, 42 }, 43 code: { 44 "@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": {},
··· 36 css: { 37 maxWidth: "none", 38 pre: { 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 40 }, 41 code: { 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+62 -41
workflow/compile.go
··· 1 package workflow 2 3 import ( 4 "fmt" 5 6 "tangled.sh/tangled.sh/core/api/tangled" 7 ) 8 9 type Compiler struct { 10 Trigger tangled.Pipeline_TriggerMetadata 11 Diagnostics Diagnostics 12 } 13 14 type Diagnostics struct { 15 - Errors []error 16 Warnings []Warning 17 } 18 19 func (d *Diagnostics) Combine(o Diagnostics) { 20 d.Errors = append(d.Errors, o.Errors...) 21 d.Warnings = append(d.Warnings, o.Warnings...) ··· 25 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 26 } 27 28 - func (d *Diagnostics) AddError(err error) { 29 - d.Errors = append(d.Errors, err) 30 } 31 32 func (d Diagnostics) IsErr() bool { 33 return len(d.Errors) != 0 34 } 35 36 type Warning struct { 37 Path string 38 Type WarningKind 39 Reason string 40 } 41 42 type WarningKind string 43 44 var ( ··· 46 InvalidConfiguration WarningKind = "invalid configuration" 47 ) 48 49 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 50 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 51 cp := tangled.Pipeline{ 52 TriggerMetadata: &compiler.Trigger, 53 } 54 55 - for _, w := range p { 56 - cw := compiler.compileWorkflow(w) 57 58 - // empty workflows are not added to the pipeline 59 - if len(cw.Steps) == 0 { 60 continue 61 } 62 63 - cp.Workflows = append(cp.Workflows, &cw) 64 } 65 66 return cp 67 } 68 69 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 70 - cw := tangled.Pipeline_Workflow{} 71 72 if !w.Match(compiler.Trigger) { 73 compiler.Diagnostics.AddWarning( ··· 75 WorkflowSkipped, 76 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 77 ) 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 88 } 89 90 // validate clone options 91 compiler.analyzeCloneOptions(w) 92 93 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) 108 } 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 - } 116 117 o := w.CloneOpts.AsRecord() 118 cw.Clone = &o
··· 1 package workflow 2 3 import ( 4 + "errors" 5 "fmt" 6 7 "tangled.sh/tangled.sh/core/api/tangled" 8 ) 9 10 + type RawWorkflow struct { 11 + Name string 12 + Contents []byte 13 + } 14 + 15 + type RawPipeline = []RawWorkflow 16 + 17 type Compiler struct { 18 Trigger tangled.Pipeline_TriggerMetadata 19 Diagnostics Diagnostics 20 } 21 22 type Diagnostics struct { 23 + Errors []Error 24 Warnings []Warning 25 } 26 27 + func (d *Diagnostics) IsEmpty() bool { 28 + return len(d.Errors) == 0 && len(d.Warnings) == 0 29 + } 30 + 31 func (d *Diagnostics) Combine(o Diagnostics) { 32 d.Errors = append(d.Errors, o.Errors...) 33 d.Warnings = append(d.Warnings, o.Warnings...) ··· 37 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 38 } 39 40 + func (d *Diagnostics) AddError(path string, err error) { 41 + d.Errors = append(d.Errors, Error{path, err}) 42 } 43 44 func (d Diagnostics) IsErr() bool { 45 return len(d.Errors) != 0 46 } 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 + 57 type Warning struct { 58 Path string 59 Type WarningKind 60 Reason string 61 } 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 + 71 type WarningKind string 72 73 var ( ··· 75 InvalidConfiguration WarningKind = "invalid configuration" 76 ) 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 + 94 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 95 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 96 cp := tangled.Pipeline{ 97 TriggerMetadata: &compiler.Trigger, 98 } 99 100 + for _, wf := range p { 101 + cw := compiler.compileWorkflow(wf) 102 103 + if cw == nil { 104 continue 105 } 106 107 + cp.Workflows = append(cp.Workflows, cw) 108 } 109 110 return cp 111 } 112 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 115 116 if !w.Match(compiler.Trigger) { 117 compiler.Diagnostics.AddWarning( ··· 119 WorkflowSkipped, 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 121 ) 122 + return nil 123 } 124 125 // validate clone options 126 compiler.analyzeCloneOptions(w) 127 128 cw.Name = w.Name 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 133 } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 137 138 o := w.CloneOpts.AsRecord() 139 cw.Clone = &o
+23 -29
workflow/compile_test.go
··· 26 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 34 CloneOpts: CloneOpts{}, // default true 35 } 36 ··· 43 assert.False(t, c.Diagnostics.IsErr()) 44 } 45 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 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 64 When: []Constraint{ 65 { 66 Event: []string{"push"}, 67 Branch: []string{"master"}, // different branch 68 }, 69 }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 - }, 73 } 74 75 c := Compiler{Trigger: trigger} ··· 82 83 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 90 CloneOpts: CloneOpts{ 91 Skip: true, 92 Depth: 1, ··· 101 assert.Len(t, c.Diagnostics.Warnings, 1) 102 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 }
··· 26 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 wf := Workflow{ 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 32 CloneOpts: CloneOpts{}, // default true 33 } 34 ··· 41 assert.False(t, c.Diagnostics.IsErr()) 42 } 43 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 45 wf := Workflow{ 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 48 When: []Constraint{ 49 { 50 Event: []string{"push"}, 51 Branch: []string{"master"}, // different branch 52 }, 53 }, 54 } 55 56 c := Compiler{Trigger: trigger} ··· 63 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 65 wf := Workflow{ 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 69 CloneOpts: CloneOpts{ 70 Skip: true, 71 Depth: 1, ··· 80 assert.Len(t, c.Diagnostics.Warnings, 1) 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 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 25 // this is simply a structural representation of the workflow file 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"` 33 } 34 35 Constraint struct { 36 Event StringList `yaml:"event"` 37 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 38 } 39 - 40 - Dependencies map[string][]string 41 42 CloneOpts struct { 43 Skip bool `yaml:"skip"` 44 Depth int `yaml:"depth"` 45 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 } 53 54 StringList []string ··· 77 } 78 79 wf.Name = name 80 81 return wf, nil 82 } ··· 173 } 174 175 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 } 196 197 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
··· 24 25 // this is simply a structural representation of the workflow file 26 Workflow struct { 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:"-"` 32 } 33 34 Constraint struct { 35 Event StringList `yaml:"event"` 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 } 38 39 CloneOpts struct { 40 Skip bool `yaml:"skip"` 41 Depth int `yaml:"depth"` 42 IncludeSubmodules bool `yaml:"submodules"` 43 } 44 45 StringList []string ··· 68 } 69 70 wf.Name = name 71 + wf.Raw = string(contents) 72 73 return wf, nil 74 } ··· 165 } 166 167 return errors.New("failed to unmarshal StringOrSlice") 168 } 169 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 yamlData := ` 11 when: 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 ./...` 25 26 wf, err := FromFile("test.yml", []byte(yamlData)) 27 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 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 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 } 43 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 func TestUnmarshalCloneFalse(t *testing.T) { 72 yamlData := ` 73 when: ··· 75 76 clone: 77 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 ` 88 89 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 94 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 } 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 - }
··· 10 yamlData := ` 11 when: 12 - event: ["push", "pull_request"] 13 + branch: ["main", "develop"]` 14 15 wf, err := FromFile("test.yml", []byte(yamlData)) 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 21 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 23 } 24 25 func TestUnmarshalCloneFalse(t *testing.T) { 26 yamlData := ` 27 when: ··· 29 30 clone: 31 skip: true 32 ` 33 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 38 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 }
+125
xrpc/errors/errors.go
···
··· 1 + package errors 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + type XrpcError struct { 9 + Tag string `json:"error"` 10 + Message string `json:"message"` 11 + } 12 + 13 + func (x XrpcError) Error() string { 14 + if x.Message != "" { 15 + return fmt.Sprintf("%s: %s", x.Tag, x.Message) 16 + } 17 + return x.Tag 18 + } 19 + 20 + func NewXrpcError(opts ...ErrOpt) XrpcError { 21 + x := XrpcError{} 22 + for _, o := range opts { 23 + o(&x) 24 + } 25 + 26 + return x 27 + } 28 + 29 + type ErrOpt = func(xerr *XrpcError) 30 + 31 + func WithTag(tag string) ErrOpt { 32 + return func(xerr *XrpcError) { 33 + xerr.Tag = tag 34 + } 35 + } 36 + 37 + func WithMessage[S ~string](s S) ErrOpt { 38 + return func(xerr *XrpcError) { 39 + xerr.Message = string(s) 40 + } 41 + } 42 + 43 + func WithError(e error) ErrOpt { 44 + return func(xerr *XrpcError) { 45 + xerr.Message = e.Error() 46 + } 47 + } 48 + 49 + var MissingActorDidError = NewXrpcError( 50 + WithTag("MissingActorDid"), 51 + WithMessage("actor DID not supplied"), 52 + ) 53 + 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 59 + var RepoNotFoundError = NewXrpcError( 60 + WithTag("RepoNotFound"), 61 + WithMessage("failed to access repository"), 62 + ) 63 + 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 69 + var AuthError = func(err error) XrpcError { 70 + return NewXrpcError( 71 + WithTag("Auth"), 72 + WithError(fmt.Errorf("signature verification failed: %w", err)), 73 + ) 74 + } 75 + 76 + var InvalidRepoError = func(r string) XrpcError { 77 + return NewXrpcError( 78 + WithTag("InvalidRepo"), 79 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 80 + ) 81 + } 82 + 83 + var GitError = func(e error) XrpcError { 84 + return NewXrpcError( 85 + WithTag("Git"), 86 + WithError(fmt.Errorf("git error: %w", e)), 87 + ) 88 + } 89 + 90 + var AccessControlError = func(d string) XrpcError { 91 + return NewXrpcError( 92 + WithTag("AccessControl"), 93 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 94 + ) 95 + } 96 + 97 + var RepoExistsError = func(r string) XrpcError { 98 + return NewXrpcError( 99 + WithTag("RepoExists"), 100 + WithError(fmt.Errorf("repo already exists: %s", r)), 101 + ) 102 + } 103 + 104 + var RecordExistsError = func(r string) XrpcError { 105 + return NewXrpcError( 106 + WithTag("RecordExists"), 107 + WithError(fmt.Errorf("repo already exists: %s", r)), 108 + ) 109 + } 110 + 111 + func GenericError(err error) XrpcError { 112 + return NewXrpcError( 113 + WithTag("Generic"), 114 + WithError(err), 115 + ) 116 + } 117 + 118 + func Unmarshal(errStr string) (XrpcError, error) { 119 + var xerr XrpcError 120 + err := json.Unmarshal([]byte(errStr), &xerr) 121 + if err != nil { 122 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 123 + } 124 + return xerr, nil 125 + }
+65
xrpc/serviceauth/service_auth.go
···
··· 1 + package serviceauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + const ActorDid string = "ActorDid" 16 + 17 + type ServiceAuth struct { 18 + logger *slog.Logger 19 + resolver *idresolver.Resolver 20 + audienceDid string 21 + } 22 + 23 + func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 + return &ServiceAuth{ 25 + logger: logger, 26 + resolver: resolver, 27 + audienceDid: audienceDid, 28 + } 29 + } 30 + 31 + func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + l := sa.logger.With("url", r.URL) 34 + 35 + token := r.Header.Get("Authorization") 36 + token = strings.TrimPrefix(token, "Bearer ") 37 + 38 + s := auth.ServiceAuthValidator{ 39 + Audience: sa.audienceDid, 40 + Dir: sa.resolver.Directory(), 41 + } 42 + 43 + did, err := s.Validate(r.Context(), token, nil) 44 + if err != nil { 45 + l.Error("signature verification failed", "err", err) 46 + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 + return 48 + } 49 + 50 + r = r.WithContext( 51 + context.WithValue(r.Context(), ActorDid, did), 52 + ) 53 + 54 + next.ServeHTTP(w, r) 55 + }) 56 + } 57 + 58 + // this is slightly different from http_util::write_error to follow the spec: 59 + // 60 + // the json object returned must include an "error" and a "message" 61 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 62 + w.Header().Set("Content-Type", "application/json") 63 + w.WriteHeader(status) 64 + json.NewEncoder(w).Encode(e) 65 + }